Add letter spacing and text transforms to GPUI (#111)

* Add text spacing and transform style plumbing

* Apply byte-stable transforms and spacing-aware truncation

* Add platform letter spacing support

* Fix scheduler clippy warning

* Refresh dependency lockfile

* Fix CI for text spacing changes

* Add text transform preview example
This commit is contained in:
Max Carter 祁明思
2026-08-07 21:48:02 -07:00
committed by GitHub
parent 568271c01c
commit 6043651563
18 changed files with 952 additions and 418 deletions
Generated
+407 -372
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -90,6 +90,7 @@ sum_tree.workspace = true
taffy = "=0.10.1"
thiserror.workspace = true
gpui_util.workspace = true
unicode-segmentation.workspace = true
hdrhistogram = { workspace = true, optional = true }
waker-fn = "1.2.0"
lyon = "1.0"
@@ -147,7 +148,6 @@ lyon = { version = "1.0", features = ["extra"] }
proptest = { workspace = true }
rand.workspace = true
scheduler = { workspace = true, features = ["test-support"] }
unicode-segmentation = { workspace = true }
[target.'cfg(target_family = "wasm")'.dev-dependencies]
@@ -0,0 +1,84 @@
use gpui::{
App, Bounds, Context, FontWeight, Render, TextTransform, Window, WindowBounds, WindowOptions,
div, prelude::*, px, rgb, size,
};
struct TextTransformPreview;
impl Render for TextTransformPreview {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_4()
.bg(rgb(0x10141c))
.size(px(720.))
.p_8()
.text_color(rgb(0xe5e7eb))
.child(
div()
.text_xl()
.font_weight(FontWeight::BOLD)
.child("Text spacing and transforms"),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.p_4()
.bg(rgb(0x1f2937))
.rounded_md()
.child(div().text_sm().text_color(rgb(0x9ca3af)).child("Uppercase"))
.child(
div()
.text_2xl()
.font_weight(FontWeight::SEMIBOLD)
.letter_spacing(px(3.))
.text_transform(TextTransform::Uppercase)
.text_color(rgb(0x93c5fd))
.child("letter spacing works"),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.p_4()
.bg(rgb(0x1f2937))
.rounded_md()
.child(
div()
.text_sm()
.text_color(rgb(0x9ca3af))
.child("Capitalize"),
)
.child(
div()
.text_2xl()
.letter_spacing(px(1.5))
.text_transform(TextTransform::Capitalize)
.text_color(rgb(0xfcd34d))
.child("each word keeps its byte offsets"),
),
)
}
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
cx.activate(true);
let bounds = Bounds::centered(None, size(px(720.), px(480.)), cx);
if let Err(error) = cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| TextTransformPreview),
) {
eprintln!("failed to open preview window: {error}");
return;
}
});
}
+132 -1
View File
@@ -2,7 +2,7 @@ use crate::{
ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow,
TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
TextRun, TextStyle, TextTransform, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window,
};
use anyhow::Context as _;
@@ -17,6 +17,7 @@ use std::{
rc::Rc,
sync::Arc,
};
use unicode_segmentation::UnicodeSegmentation;
/// An [`Element`] that renders text.
///
@@ -622,6 +623,134 @@ struct TextLayoutInner {
bounds: Option<Bounds<Pixels>>,
}
fn apply_text_transform_preserving_byte_len(
text: SharedString,
transform: Option<TextTransform>,
) -> SharedString {
let Some(transform) = transform else {
return text;
};
if matches!(transform, TextTransform::None) {
return text;
}
let mut output = String::with_capacity(text.len());
match transform {
TextTransform::Uppercase => {
for character in text.as_ref().chars() {
push_case_mapped_character(&mut output, character, CaseMapKind::Upper);
}
}
TextTransform::Lowercase => {
for character in text.as_ref().chars() {
push_case_mapped_character(&mut output, character, CaseMapKind::Lower);
}
}
TextTransform::Capitalize => {
for piece in text.as_ref().split_word_bounds() {
let mut seen_first_letter = false;
for character in piece.chars() {
if !seen_first_letter && character.is_alphabetic() {
push_case_mapped_character(&mut output, character, CaseMapKind::Upper);
seen_first_letter = true;
} else {
output.push(character);
}
}
}
}
TextTransform::None => return text,
}
SharedString::from(output)
}
#[derive(Copy, Clone)]
enum CaseMapKind {
Upper,
Lower,
}
fn push_case_mapped_character(output: &mut String, character: char, kind: CaseMapKind) {
let mapped = match kind {
CaseMapKind::Upper => character.to_uppercase().collect::<String>(),
CaseMapKind::Lower => character.to_lowercase().collect::<String>(),
};
if mapped.len() == character.len_utf8() && mapped.chars().count() == 1 {
output.push_str(&mapped);
} else {
output.push(character);
}
}
#[cfg(test)]
mod text_transform_tests {
use super::apply_text_transform_preserving_byte_len;
use crate::{SharedString, TextTransform};
#[test]
fn text_transforms_preserve_bytes_and_spacing() {
let input = SharedString::from("hello WORLD\tfoo-bar 123baz déjà vu");
let uppercase =
apply_text_transform_preserving_byte_len(input.clone(), Some(TextTransform::Uppercase));
let lowercase =
apply_text_transform_preserving_byte_len(input.clone(), Some(TextTransform::Lowercase));
let capitalize = apply_text_transform_preserving_byte_len(
input.clone(),
Some(TextTransform::Capitalize),
);
assert_eq!(uppercase.as_ref(), "HELLO WORLD\tFOO-BAR 123BAZ DÉJÀ VU");
assert_eq!(lowercase.as_ref(), "hello world\tfoo-bar 123baz déjà vu");
assert_eq!(capitalize.as_ref(), "Hello WORLD\tFoo-Bar 123Baz Déjà Vu");
assert_eq!(input.len(), uppercase.len());
assert_eq!(input.len(), lowercase.len());
assert_eq!(input.len(), capitalize.len());
}
#[test]
fn text_transforms_skip_expanding_unicode_mappings() {
let input = SharedString::from("straße İSTANBUL");
let uppercase =
apply_text_transform_preserving_byte_len(input.clone(), Some(TextTransform::Uppercase));
let lowercase =
apply_text_transform_preserving_byte_len(input.clone(), Some(TextTransform::Lowercase));
assert_eq!(uppercase.as_ref(), "STRAßE İSTANBUL");
assert_eq!(lowercase.as_ref(), "straße İstanbul");
assert_eq!(input.len(), uppercase.len());
assert_eq!(input.len(), lowercase.len());
}
#[test]
fn capitalize_preserves_letters_after_digit_prefix() {
let input = SharedString::from("123BAZ");
let output = apply_text_transform_preserving_byte_len(
input.clone(),
Some(TextTransform::Capitalize),
);
assert_eq!(output.as_ref(), "123BAZ");
assert_eq!(input.len(), output.len());
}
#[test]
fn capitalize_does_not_fold_remaining_letters() {
let input = SharedString::from("foo2BAR");
let output =
apply_text_transform_preserving_byte_len(input, Some(TextTransform::Capitalize));
assert_eq!(output.as_ref(), "Foo2BAR");
}
#[test]
fn capitalize_handles_apostrophe_contractions() {
let input = SharedString::from("don't panic");
let output =
apply_text_transform_preserving_byte_len(input, Some(TextTransform::Capitalize));
assert_eq!(output.as_ref(), "Don't Panic");
}
}
/// Metadata about how text should be truncated. Generated during text layout via `TextLayout::evaluate_overflow`.
pub struct TextLayoutTruncation {
/// The width that the text can occupy before it is truncated.
@@ -711,6 +840,7 @@ impl TextLayout {
cx: &mut App,
) -> (SharedString, Cow<'runs, [TextRun]>) {
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
line_wrapper.set_letter_spacing(text_style.letter_spacing);
if truncation.width.is_some() {
if let Some(max_lines) = text_style.line_clamp
&& let Some(wrap_width) = wrap_width
@@ -745,6 +875,7 @@ impl TextLayout {
_: &mut App,
) -> LayoutId {
let text_style = window.text_style();
let text = apply_text_transform_preserving_byte_len(text, text_style.text_transform);
let font_size = text_style.font_size.to_pixels(window.rem_size());
let line_height = window.pixel_snap(
text_style
+19 -5
View File
@@ -945,7 +945,7 @@ impl PlatformTextSystem for NoopTextSystem {
Ok((raster_bounds.size, Vec::new()))
}
fn layout_line(&self, text: &str, font_size: Pixels, _runs: &[FontRun]) -> LineLayout {
fn layout_line(&self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout {
let mut position = px(0.);
let metrics = self.font_metrics(FontId(0));
let em_width = font_size
@@ -972,9 +972,9 @@ impl PlatformTextSystem for NoopTextSystem {
position += em_width
}
}
let mut runs = Vec::default();
let mut shaped_runs = Vec::default();
if !glyphs.is_empty() {
runs.push(ShapedRun {
shaped_runs.push(ShapedRun {
font_id: FontId(0),
glyphs,
});
@@ -982,12 +982,26 @@ impl PlatformTextSystem for NoopTextSystem {
position = px(0.);
}
let mut tracking = px(0.);
let mut byte_offset = 0usize;
for run in font_runs {
let end = byte_offset.saturating_add(run.len).min(text.len());
let slice = text.get(byte_offset..end).unwrap_or("");
let n = slice.chars().count();
if n > 1 {
if let Some(spacing) = run.letter_spacing {
tracking += spacing * (n - 1) as f32;
}
}
byte_offset = byte_offset.saturating_add(run.len);
}
LineLayout {
font_size,
width: position,
width: position + tracking,
ascent: font_size * (metrics.ascent / metrics.units_per_em as f32),
descent: font_size * (metrics.descent / metrics.units_per_em as f32),
runs,
runs: shaped_runs,
len: text.len(),
}
}
+43
View File
@@ -441,6 +441,33 @@ pub enum TextAlign {
Right,
}
/// Case mapping applied at layout time while keeping **UTF-8 byte lengths** unchanged.
///
/// [`TextTransform::Uppercase`] and [`TextTransform::Lowercase`] use Unicode full case folding via
/// [`char::to_uppercase`] / [`char::to_lowercase`]. If mapping a code point would change its UTF-8
/// length, or would replace one scalar value with more than one character, the **original**
/// character is kept. That keeps indices into the underlying buffer aligned with hit-testing and
/// editor-style caret positions that use raw byte offsets.
///
/// [`TextTransform::Capitalize`] matches CSS / Tailwind [`capitalize`][tw-cap] using Unicode word
/// boundaries: only the first alphabetic code point in each word is uppercased; other characters
/// are unchanged.
///
/// [tw-cap]: https://tailwindcss.com/docs/text-transform
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum TextTransform {
/// Do not transform text.
#[default]
None,
/// Uppercase text (Unicode, byte-length preserving — see [`TextTransform`]).
Uppercase,
/// Lowercase text (Unicode, byte-length preserving — see [`TextTransform`]).
Lowercase,
/// `text-transform: capitalize` semantics (Tailwind class `capitalize`): per Unicode word, only
/// the first alphabetic character is mapped to uppercase; the rest of the string is unchanged.
Capitalize,
}
/// The properties that can be used to style text in GPUI
#[derive(Refineable, Clone, Debug, PartialEq)]
#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -489,6 +516,14 @@ pub struct TextStyle {
/// The number of lines to display before truncating the text
pub line_clamp: Option<usize>,
/// Letter spacing added between characters, in pixels (positive widens, negative tightens).
///
/// The platform text stack may clamp values outside the range it supports.
pub letter_spacing: Option<Pixels>,
/// Case transformation applied at layout time.
pub text_transform: Option<TextTransform>,
}
impl Default for TextStyle {
@@ -510,6 +545,8 @@ impl Default for TextStyle {
text_overflow: None,
text_align: TextAlign::default(),
line_clamp: None,
letter_spacing: None,
text_transform: None,
}
}
}
@@ -579,12 +616,18 @@ impl TextStyle {
background_color: self.background_color,
underline: self.underline,
strikethrough: self.strikethrough,
letter_spacing: self.letter_spacing,
}
}
}
/// A highlight style to apply, similar to a `TextStyle` except
/// for a single font, uniformly sized and spaced text.
///
/// Layout extras on the base [`TextStyle`] — such as [`TextStyle::letter_spacing`] and
/// [`TextStyle::text_transform`] — are not stored here; highlighted segments inherit them
/// from the surrounding style (see [`TextStyle::highlight`] and
/// [`crate::StyledText::with_default_highlights`]).
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct HighlightStyle {
/// The color of the text
+13 -1
View File
@@ -3,7 +3,7 @@ use crate::{
DefiniteLength, Display, Fill, Filter, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle,
FontWeight, GridPlacement, GridTemplate, Hsla, JustifyContent, Length, Pixels, SharedString,
StrikethroughStyle, StyleRefinement, TemplateColumnMinSize, TextAlign, TextOverflow,
TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems,
TextStyleRefinement, TextTransform, UnderlineStyle, WhiteSpace, px, relative, rems,
};
pub use gpui_macros::{
border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods,
@@ -173,6 +173,18 @@ pub trait Styled: Sized {
self.text_align(TextAlign::Right)
}
/// Sets the letter spacing for text in this element and its children.
fn letter_spacing(mut self, spacing: impl Into<Pixels>) -> Self {
self.text_style().letter_spacing = Some(spacing.into());
self
}
/// Sets the case transformation for text in this element and its children.
fn text_transform(mut self, transform: TextTransform) -> Self {
self.text_style().text_transform = Some(transform);
self
}
/// Sets the truncate to prevent text from wrapping and truncate overflowing text with an ellipsis (…) if needed.
/// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
fn truncate(mut self) -> Self {
+26 -1
View File
@@ -215,6 +215,7 @@ impl TextSystem {
&[FontRun {
len: buffer.len(),
font_id,
letter_spacing: None,
}],
)
.width
@@ -359,6 +360,14 @@ impl TextSystem {
}
}
#[cfg(test)]
impl TextSystem {
/// Reach the platform shaper from crate tests (e.g. `line_wrapper`) without a [`WindowTextSystem`].
pub(crate) fn platform_text_system_for_tests(&self) -> Arc<dyn PlatformTextSystem> {
self.platform_text_system.clone()
}
}
/// The GPUI text layout subsystem.
#[derive(Deref)]
pub struct WindowTextSystem {
@@ -563,8 +572,10 @@ impl WindowTextSystem {
};
let font_id = self.resolve_font(&run.font);
let letter_spacing = run.letter_spacing;
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& font_run.letter_spacing == letter_spacing
&& !decoration_changed
{
font_run.len += run_len_within_line;
@@ -572,6 +583,7 @@ impl WindowTextSystem {
font_runs.push(FontRun {
len: run_len_within_line,
font_id,
letter_spacing,
});
}
@@ -677,8 +689,10 @@ impl WindowTextSystem {
};
let font_id = self.resolve_font(&run.font);
let letter_spacing = run.letter_spacing;
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& font_run.letter_spacing == letter_spacing
&& !decoration_changed
{
font_run.len += run.len;
@@ -686,6 +700,7 @@ impl WindowTextSystem {
font_runs.push(FontRun {
len: run.len,
font_id,
letter_spacing,
});
}
}
@@ -713,6 +728,7 @@ impl WindowTextSystem {
&[FontRun {
len: buffer.len(),
font_id,
letter_spacing: None,
}],
None,
)
@@ -759,8 +775,10 @@ impl WindowTextSystem {
};
let font_id = self.resolve_font(&run.font);
let letter_spacing = run.letter_spacing;
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& font_run.letter_spacing == letter_spacing
&& !decoration_changed
{
font_run.len += run.len;
@@ -768,6 +786,7 @@ impl WindowTextSystem {
font_runs.push(FontRun {
len: run.len,
font_id,
letter_spacing,
});
}
}
@@ -821,8 +840,10 @@ impl WindowTextSystem {
};
let font_id = self.resolve_font(&run.font);
let letter_spacing = run.letter_spacing;
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& font_run.letter_spacing == letter_spacing
&& !decoration_changed
{
font_run.len += run.len;
@@ -830,6 +851,7 @@ impl WindowTextSystem {
font_runs.push(FontRun {
len: run.len,
font_id,
letter_spacing,
});
}
}
@@ -864,7 +886,8 @@ pub struct LineWrapperHandle {
impl Drop for LineWrapperHandle {
fn drop(&mut self) {
let mut state = self.text_system.wrapper_pool.lock();
let wrapper = self.wrapper.take().unwrap();
let mut wrapper = self.wrapper.take().unwrap();
wrapper.set_letter_spacing(None);
state
.get_mut(&FontIdWithSize {
font_id: wrapper.font_id,
@@ -1006,6 +1029,8 @@ pub struct TextRun {
pub underline: Option<UnderlineStyle>,
/// The strikethrough style (if any)
pub strikethrough: Option<StrikethroughStyle>,
/// Letter spacing applied between glyphs, in pixels.
pub letter_spacing: Option<Pixels>,
}
#[cfg(all(target_os = "macos", test))]
+19 -1
View File
@@ -810,11 +810,29 @@ fn apply_force_width_to_layout(layout: &mut LineLayout, force_width: Pixels) {
}
/// A run of text with a single font.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[expect(missing_docs)]
pub struct FontRun {
pub len: usize,
pub font_id: FontId,
pub letter_spacing: Option<Pixels>,
}
impl Hash for FontRun {
fn hash<H: Hasher>(&self, state: &mut H) {
self.len.hash(state);
self.font_id.hash(state);
self.letter_spacing
.map(Pixels::as_f32)
.map(|value| {
if value == 0.0 {
0.0f32.to_bits()
} else {
value.to_bits()
}
})
.hash(state);
}
}
trait AsCacheKeyRef {
+133 -16
View File
@@ -16,6 +16,7 @@ pub struct LineWrapper {
text_system: Arc<TextSystem>,
pub(crate) font_id: FontId,
pub(crate) font_size: Pixels,
letter_spacing: Option<Pixels>,
cached_ascii_char_widths: [Option<Pixels>; 128],
cached_other_char_widths: HashMap<char, Pixels>,
}
@@ -29,11 +30,16 @@ impl LineWrapper {
text_system,
font_id,
font_size,
letter_spacing: None,
cached_ascii_char_widths: [None; 128],
cached_other_char_widths: HashMap::default(),
}
}
pub(crate) fn set_letter_spacing(&mut self, letter_spacing: Option<Pixels>) {
self.letter_spacing = letter_spacing;
}
/// Wrap a line of text to the given width with this wrapper's font and font size.
pub fn wrap_line<'a>(
&'a mut self,
@@ -48,6 +54,7 @@ impl LineWrapper {
let mut last_wrap_ix = 0;
let mut prev_c = '\0';
let mut index = 0;
let mut previous_text_character = false;
let mut candidates = fragments
.iter()
.flat_map(move |fragment| fragment.wrap_boundary_candidates())
@@ -57,9 +64,11 @@ impl LineWrapper {
let ix = index;
index += candidate.len_utf8();
let mut new_prev_c = prev_c;
let mut item_had_spacing = false;
let item_width = match candidate {
WrapBoundaryCandidate::Char { character: c } => {
if c == '\n' {
previous_text_character = false;
continue;
}
@@ -82,7 +91,15 @@ impl LineWrapper {
new_prev_c = c;
self.width_for_char(c)
let width = self.width_for_char(c);
let spacing = if previous_text_character {
self.letter_spacing.unwrap_or_default()
} else {
px(0.)
};
item_had_spacing = previous_text_character;
previous_text_character = true;
width + spacing
}
WrapBoundaryCandidate::Element {
width: element_width,
@@ -113,10 +130,15 @@ impl LineWrapper {
if last_candidate_ix > 0 {
last_wrap_ix = last_candidate_ix;
width -= last_candidate_width;
width -= self.letter_spacing.unwrap_or_default();
last_candidate_ix = 0;
} else {
last_wrap_ix = ix;
width = item_width;
width = if item_had_spacing {
item_width - self.letter_spacing.unwrap_or_default()
} else {
item_width
};
}
if let Some(indent) = indent {
@@ -144,20 +166,24 @@ impl LineWrapper {
truncate_from: TruncateFrom,
) -> Option<usize> {
let mut width = px(0.);
let suffix_width = truncation_affix
.chars()
.map(|c| self.width_for_char(c))
.fold(px(0.0), |a, x| a + x);
let suffix_width = self.width_for_text(truncation_affix)
+ if truncation_affix.is_empty() {
px(0.)
} else {
self.letter_spacing.unwrap_or_default()
};
let mut truncate_ix = 0;
match truncate_from {
TruncateFrom::Start => {
let mut previous_text_character = false;
for (ix, c) in line.char_indices().rev() {
if width + suffix_width < truncate_width {
truncate_ix = ix;
}
let char_width = self.width_for_char(c);
let char_width =
self.width_for_char_with_spacing(c, &mut previous_text_character);
width += char_width;
if width.floor() > truncate_width {
@@ -166,12 +192,14 @@ impl LineWrapper {
}
}
TruncateFrom::End => {
let mut previous_text_character = false;
for (ix, c) in line.char_indices() {
if width + suffix_width < truncate_width {
truncate_ix = ix;
}
let char_width = self.width_for_char(c);
let char_width =
self.width_for_char_with_spacing(c, &mut previous_text_character);
width += char_width;
if width.floor() > truncate_width {
@@ -243,10 +271,12 @@ impl LineWrapper {
);
}
let affix_width: Pixels = truncation_affix
.chars()
.map(|c| self.width_for_char(c))
.sum();
let affix_width = self.width_for_text(truncation_affix)
+ if truncation_affix.is_empty() {
px(0.)
} else {
self.letter_spacing.unwrap_or_default()
};
let mut width = px(0.);
let mut line = 0usize;
@@ -257,6 +287,7 @@ impl LineWrapper {
let mut prev_c = '\0';
let mut indent: Option<u32> = None;
let mut truncate_ix = 0usize;
let mut previous_text_character = false;
for (ix, c) in text.char_indices() {
if c == '\n' {
@@ -286,10 +317,11 @@ impl LineWrapper {
prev_c = '\0';
indent = None;
truncate_ix = ix + 1;
previous_text_character = false;
continue;
}
let char_width = self.width_for_char(c);
let char_width = self.width_for_char_with_spacing(c, &mut previous_text_character);
if Self::is_word_char(c) {
if prev_c == ' ' && first_non_whitespace_ix.is_some() {
@@ -317,10 +349,11 @@ impl LineWrapper {
if last_candidate_ix > last_wrap_ix {
last_wrap_ix = last_candidate_ix;
width -= last_candidate_width;
width -= self.letter_spacing.unwrap_or_default();
last_candidate_ix = 0;
} else {
last_wrap_ix = ix;
width = char_width;
width = self.width_for_char(c);
}
if let Some(ind) = indent {
@@ -416,6 +449,30 @@ impl LineWrapper {
width
}
}
fn width_for_char_with_spacing(
&mut self,
c: char,
previous_text_character: &mut bool,
) -> Pixels {
let width = self.width_for_char(c);
let spacing = if *previous_text_character {
self.letter_spacing.unwrap_or_default()
} else {
px(0.)
};
*previous_text_character = true;
width + spacing
}
fn width_for_text(&mut self, text: &str) -> Pixels {
let mut previous_text_character = false;
text.chars()
.map(|character| {
self.width_for_char_with_spacing(character, &mut previous_text_character)
})
.sum()
}
}
fn update_runs_after_truncation(
@@ -528,9 +585,12 @@ impl Boundary {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
use crate::{
Font, FontFeatures, FontRun, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher,
TextRun, font,
};
#[cfg(target_os = "macos")]
use crate::{TextRun, WindowTextSystem, WrapBoundary};
use crate::{WindowTextSystem, WrapBoundary};
fn build_wrapper() -> LineWrapper {
let dispatcher = TestDispatcher::new(0);
@@ -556,6 +616,63 @@ mod tests {
.collect()
}
#[test]
fn test_tracking_changes_measured_width() {
let dispatcher = TestDispatcher::new(1);
let cx = TestAppContext::build(dispatcher, None);
let base = TextRun {
len: 4,
font: font(".ZedMono"),
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
..Default::default()
};
let font_id = cx.text_system().resolve_font(&base.font);
let platform = cx.text_system().platform_text_system_for_tests();
let no_tracking = platform.layout_line(
"TEST",
px(16.),
&[FontRun {
len: 4,
font_id,
letter_spacing: None,
}],
);
let wide = platform.layout_line(
"TEST",
px(16.),
&[FontRun {
len: 4,
font_id,
letter_spacing: Some(px(2.0)),
}],
);
let tight = platform.layout_line(
"TEST",
px(16.),
&[FontRun {
len: 4,
font_id,
letter_spacing: Some(px(-0.5)),
}],
);
assert!(wide.width > no_tracking.width);
assert!(tight.width <= no_tracking.width);
}
#[test]
fn test_tracking_changes_truncation_width() {
let mut wrapper = build_wrapper();
let no_tracking = wrapper.width_for_text("TEST…");
wrapper.set_letter_spacing(Some(px(2.0)));
let wide = wrapper.width_for_text("TEST…");
assert!(wide > no_tracking);
}
#[test]
fn test_wrap_line() {
let mut wrapper = build_wrapper();
@@ -585,6 +585,7 @@ impl PrelayoutState {
background_color: None,
underline: None,
strikethrough: None,
letter_spacing: None,
}];
let wrap_width = TextLayout::evaluate_wrap_width(
+1 -1
View File
@@ -499,7 +499,7 @@ impl Platform for MacPlatform {
pool.drain();
(*app).set_ivar(MAC_PLATFORM_IVAR, null_mut::<c_void>());
(*NSWindow::delegate(app)).set_ivar(MAC_PLATFORM_IVAR, null_mut::<c_void>());
(*app_delegate).set_ivar(MAC_PLATFORM_IVAR, null_mut::<c_void>());
}
}
+47 -8
View File
@@ -22,7 +22,7 @@ use core_text::{
kCTFontWidthTrait,
},
line::CTLine,
string_attributes::kCTFontAttributeName,
string_attributes::{kCTFontAttributeName, kCTKernAttributeName},
};
use font_kit::{
font::Font as FontKitFont,
@@ -543,6 +543,13 @@ impl MacTextSystemState {
kCTFontAttributeName,
&font.native_font().clone_with_font_size(font_size.into()),
);
if let Some(spacing) = run.letter_spacing {
string.set_attribute(
cf_range,
kCTKernAttributeName,
&CFNumber::from(f64::from(spacing.as_f32())),
);
}
}
break_ligature = !break_ligature;
}
@@ -753,6 +760,7 @@ mod tests {
let mut style = FontRun {
font_id,
len: line.len(),
letter_spacing: None,
};
let layout = fonts.layout_line(line, px(16.), &[style]);
@@ -774,10 +782,12 @@ mod tests {
FontRun {
len: "\u{feff}".len(),
font_id,
letter_spacing: None,
},
FontRun {
len: "ab".len(),
font_id,
letter_spacing: None,
},
];
let layout = fonts.layout_line(line, px(16.), font_runs);
@@ -796,8 +806,16 @@ mod tests {
let text = "hello world";
let font_runs = &[
FontRun { font_id, len: 5 }, // "hello"
FontRun { font_id, len: 6 }, // " world"
FontRun {
font_id,
len: 5,
letter_spacing: None,
}, // "hello"
FontRun {
font_id,
len: 6,
letter_spacing: None,
}, // " world"
];
let layout = fonts.layout_line(text, px(16.), font_runs);
@@ -817,11 +835,16 @@ mod tests {
// Test with different font runs - should not insert ZWNJ
let font_id2 = fonts.font_id(&font("Times")).unwrap_or(font_id);
let font_runs_different = &[
FontRun { font_id, len: 5 }, // "hello"
FontRun {
font_id,
len: 5,
letter_spacing: None,
}, // "hello"
// " world"
FontRun {
font_id: font_id2,
len: 6,
letter_spacing: None,
},
];
@@ -846,15 +869,31 @@ mod tests {
let font_id = fonts.font_id(&font("Helvetica")).unwrap();
let text = "hello";
let font_runs = &[FontRun { font_id, len: 5 }];
let font_runs = &[FontRun {
font_id,
len: 5,
letter_spacing: None,
}];
let layout = fonts.layout_line(text, px(16.), font_runs);
assert_eq!(layout.len, text.len());
let text = "abc";
let font_runs = &[
FontRun { font_id, len: 1 }, // "a"
FontRun { font_id, len: 1 }, // "b"
FontRun { font_id, len: 1 }, // "c"
FontRun {
font_id,
len: 1,
letter_spacing: None,
}, // "a"
FontRun {
font_id,
len: 1,
letter_spacing: None,
}, // "b"
FontRun {
font_id,
len: 1,
letter_spacing: None,
}, // "c"
];
let layout = fonts.layout_line(text, px(16.), font_runs);
assert_eq!(layout.len, text.len());
+1 -1
View File
@@ -58,7 +58,7 @@ impl TestScheduler {
.map(|seed| seed.parse().unwrap())
.unwrap_or(0);
let interactive = !std::env::var("SCHEDULER_NONINTERACTIVE").is_ok();
let interactive = std::env::var("SCHEDULER_NONINTERACTIVE").is_err();
(seed..seed + num_iterations as u64)
.map(|seed| {
+2
View File
@@ -59,10 +59,12 @@ fn bench_layout_line(c: &mut Criterion) {
let runs_no_fallback = vec![FontRun {
len: text.len(),
font_id: font_id_no_fallback,
letter_spacing: None,
}];
let runs_with_fallback = vec![FontRun {
len: text.len(),
font_id: font_id_with_fallback,
letter_spacing: None,
}];
let mut group = c.benchmark_group("layout_line");
+13 -3
View File
@@ -483,26 +483,36 @@ impl CosmicTextSystemState {
let primary_weight = face.weight;
let primary_features = loaded_font.features.clone();
let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain);
let letter_spacing = run
.letter_spacing
.map(|spacing| spacing.as_f32() / font_size.as_f32());
// build one `Attrs` per slot up front. each clone of span attrs
// would otherwise re-allocate the `font_features` Vec.
let primary_attrs = Attrs::new()
let mut primary_attrs = Attrs::new()
.metadata(run.font_id.0)
.family(Family::Name(&primary_family_name))
.stretch(primary_stretch)
.style(primary_style)
.weight(primary_weight)
.font_features(primary_features.clone());
if let Some(letter_spacing) = letter_spacing {
primary_attrs = primary_attrs.letter_spacing(letter_spacing);
}
let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain
.iter()
.map(|(fb_id, fb_name)| {
Attrs::new()
let mut attrs = Attrs::new()
.metadata(fb_id.0)
.family(Family::Name(fb_name))
.stretch(primary_stretch)
.style(primary_style)
.weight(primary_weight)
.font_features(primary_features.clone())
.font_features(primary_features.clone());
if let Some(letter_spacing) = letter_spacing {
attrs = attrs.letter_spacing(letter_spacing);
}
attrs
})
.collect();
+10 -6
View File
@@ -551,12 +551,10 @@ impl DirectWriteState {
format.SetFontFallback(fallbacks)?;
}
let layout = components.factory.CreateTextLayout(
text_wide,
&format,
f32::INFINITY,
f32::INFINITY,
)?;
let layout: IDWriteTextLayout1 = components
.factory
.CreateTextLayout(text_wide, &format, f32::INFINITY, f32::INFINITY)?
.cast()?;
let current_text = &text[utf8_offset..(utf8_offset + first_run.len)];
utf8_offset += first_run.len;
let current_text_utf16_length = current_text.encode_utf16().count() as u32;
@@ -565,6 +563,9 @@ impl DirectWriteState {
length: current_text_utf16_length,
};
layout.SetTypography(&font_info.features, text_range)?;
if let Some(spacing) = first_run.letter_spacing {
layout.SetCharacterSpacing(0.0, spacing.as_f32(), 0.0, text_range)?;
}
utf16_offset += current_text_utf16_length;
layout
@@ -603,6 +604,9 @@ impl DirectWriteState {
text_layout.SetFontStyle(font_info.font_face.GetStyle(), text_range)?;
text_layout.SetFontWeight(font_info.font_face.GetWeight(), text_range)?;
text_layout.SetTypography(&font_info.features, text_range)?;
if let Some(spacing) = run.letter_spacing {
text_layout.SetCharacterSpacing(0.0, spacing.as_f32(), 0.0, text_range)?;
}
break_ligatures = !break_ligatures;
}
-1
View File
@@ -2783,7 +2783,6 @@ mod tests {
assert_eq!(path.extension_or_hidden_file_name(), Some("eslintrc.js"));
}
#[test]
// fn edge_of_glob() {
// let path = Path::new("/work/node_modules");
// let path_matcher =