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:
Generated
+407
-372
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user