Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/platform/mac/text_layout.rs
StratoSDK / crates / strato-ui-renderer / src / platform / mac / text_layout.rs
1use block::{Block, ConcreteBlock};
2use core_foundation::array::CFArray;
3use core_foundation::attributed_string::CFMutableAttributedStringRef;
4use core_foundation::base::CFType;
5use core_foundation::boolean::CFBoolean;
6use core_foundation::dictionary::CFDictionary;
7use core_foundation::mach_port::CFIndex;
8use core_foundation::number::CFNumber;
9use core_foundation::{
10 attributed_string::CFMutableAttributedString,
11 base::CFTypeID,
12 base::{CFRange, TCFType},
13 declare_TCFType, impl_TCFType,
14 string::CFString,
15};
16use core_graphics::base::CGFloat;
17use core_graphics::color::CGColor;
18use core_graphics::display::{CGPoint, CGRect, CGSize};
19use core_graphics::path::CGPath;
20use core_text::framesetter::CTFramesetter;
21use core_text::line::CTLineRef;
22use core_text::run::{CTRun, CTRunRef};
23use core_text::string_attributes::kCTKernAttributeName;
24use core_text::{
25 font::CTFont,
26 line::CTLine,
27 string_attributes::{kCTFontAttributeName, kCTParagraphStyleAttributeName},
28};
29use itertools::Itertools;
30use ordered_float::OrderedFloat;
31use pathfinder_geometry::vector::vec2f;
32use std::borrow::Cow;
33use std::cell::RefCell;
34use std::ffi::c_void;
35use std::marker::PhantomData;
36use std::ops::Range;
37use std::rc::Rc;
38use std::slice;
39use strato_ui_core::fonts::GlyphId;
40use strato_ui_core::platform::LineStyle;
41use strato_ui_core::text_layout::{
42 CaretPosition, ClipConfig, Glyph, Line, Run, StyleAndFont, TextAlignment, TextBorder,
43 TextFrame, TextStyle,
44};
45use vec1::Vec1;
46 
47use super::fonts::FontDB;
48use super::utils::{cg_color_to_color_u, color_u_to_cg_color};
49 
50pub enum __CTParagraphStyle {}
51type CTParagraphStyleRef = *const __CTParagraphStyle;
52 
53declare_TCFType!(CTParagraphStyle, CTParagraphStyleRef);
54impl_TCFType!(
55 CTParagraphStyle,
56 CTParagraphStyleRef,
57 CTParagraphStyleGetTypeID
58);
59 
60/// From https://developer.apple.com/documentation/coretext/ctparagraphstylespecifier
61/// Note: there are many more of these possible style specifiers
62/// but we are not using them!
63#[derive(Clone, Copy)]
64#[repr(u32)]
65enum CTParagraphStyleSpecifier {
66 FirstLineHeadIndent = 1,
67 TabStops = 4,
68 DefaultTabInterval = 5,
69 LineHeightMultiple = 7,
70}
71 
72/// See https://developer.apple.com/documentation/coretext/ctparagraphstylesetting
73/// for the API specification on paragraph style settings.
74/// We tie the lifetime of this struct to ParagraphStyleSetting to ensure
75/// that we don't drop the `value` before consuming the setting (in the context
76/// of creating an attributed string).
77#[repr(C)]
78struct CTParagraphStyleSetting<'a> {
79 spec: CTParagraphStyleSpecifier,
80 value_size: usize,
81 value: *const c_void,
82 /// PhantomData is used to add a marker that CTParagraphStyleSetting should not
83 /// live any longer than ParagraphStyleSetting is alive.
84 _phantom: PhantomData<&'a ParagraphStyleSetting>,
85}
86 
87enum ParagraphStyleValue {
88 Float(Box<CGFloat>),
89 Array {
90 _array: CFArray<CFType>,
91 stable_ref: Box<*const c_void>,
92 },
93}
94 
95/// We wrap CTParagraphStyleSetting with a custom struct to correctly hold a raw
96/// pointer to a heap-allocated value, which is needed for Core Foundation FFIs.
97struct ParagraphStyleSetting {
98 /// The specific style we are trying to define for the paragraph.
99 spec: CTParagraphStyleSpecifier,
100 value: ParagraphStyleValue,
101}
102 
103impl ParagraphStyleSetting {
104 fn new_float_setting(spec: CTParagraphStyleSpecifier, value: CGFloat) -> ParagraphStyleSetting {
105 ParagraphStyleSetting {
106 spec,
107 value: ParagraphStyleValue::Float(Box::new(value)),
108 }
109 }
110 
111 fn new_empty_tab_stops() -> ParagraphStyleSetting {
112 // Core Text has a built-in default list of tab stops. Setting DefaultTabInterval alone
113 // doesn't override those initial stops, so we set an explicit (empty) TabStops array
114 // to force Core Text to use DefaultTabInterval from the first tab.
115 let array: CFArray<CFType> = CFArray::from_CFTypes(&[]);
116 let array_ref = array.as_concrete_TypeRef() as *const c_void;
117 
118 ParagraphStyleSetting {
119 spec: CTParagraphStyleSpecifier::TabStops,
120 value: ParagraphStyleValue::Array {
121 _array: array,
122 stable_ref: Box::new(array_ref),
123 },
124 }
125 }
126 
127 fn to_ct_setting(&self) -> CTParagraphStyleSetting<'_> {
128 match &self.value {
129 ParagraphStyleValue::Float(val) => {
130 let raw_ptr = val.as_ref() as *const CGFloat as *const c_void;
131 
132 CTParagraphStyleSetting {
133 spec: self.spec,
134 value_size: std::mem::size_of::<CGFloat>(),
135 value: raw_ptr,
136 _phantom: PhantomData,
137 }
138 }
139 ParagraphStyleValue::Array { stable_ref, .. } => CTParagraphStyleSetting {
140 spec: self.spec,
141 value_size: std::mem::size_of::<*const c_void>(),
142 value: stable_ref.as_ref() as *const *const c_void as *const c_void,
143 _phantom: PhantomData,
144 },
145 }
146 }
147}
148 
149/// ParagraphStyle is a wrapper struct that helps tie the underlying lifetimes of the settings
150/// being used to the CTParagraphStyle.
151struct ParagraphStyle<'a> {
152 style: CTParagraphStyle,
153 _settings: Vec<CTParagraphStyleSetting<'a>>,
154}
155 
156impl<'a> ParagraphStyle<'a> {
157 fn new(ct_style_settings: Vec<CTParagraphStyleSetting<'a>>) -> ParagraphStyle<'a> {
158 let paragraph_style = unsafe {
159 CTParagraphStyle::wrap_under_create_rule(CTParagraphStyleCreate(
160 ct_style_settings.as_slice().as_ptr(),
161 ct_style_settings.len(),
162 ))
163 };
164 
165 ParagraphStyle {
166 style: paragraph_style,
167 _settings: ct_style_settings,
168 }
169 }
170}
171 
172extern "C" {
173 fn CFAttributedStringBeginEditing(mutable_string: CFMutableAttributedStringRef);
174 
175 fn CFAttributedStringEndEditing(mutable_string: CFMutableAttributedStringRef);
176 
177 /// Enumerates caret offsets for characters in a line.
178 /// See [CTLineEnumerateCaretOffsets](https://developer.apple.com/documentation/coretext/1508685-ctlineenumeratecaretoffsets?language=objc).
179 #[allow(improper_ctypes)]
180 // Rust doesn't consider &Block a valid FFI type, but it is the correct thing
181 // as long as the block crate invariants are upheld.
182 fn CTLineEnumerateCaretOffsets(
183 line: CTLineRef,
184 block: &Block<(f64, CFIndex, bool, *mut bool), ()>,
185 );
186 
187 fn CTLineGetTrailingWhitespaceWidth(line: CTLineRef) -> f64;
188 
189 fn CTParagraphStyleGetTypeID() -> CFTypeID;
190 fn CTParagraphStyleCreate(
191 settings: *const CTParagraphStyleSetting,
192 count: usize,
193 ) -> CTParagraphStyleRef;
194 
195 fn CTRunGetGlyphCount(run: CTRunRef) -> CFIndex;
196 fn CTRunGetAdvancesPtr(run: CTRunRef) -> *const CGSize;
197 fn CTRunGetAdvances(run: CTRunRef, range: CFRange, buffer: *mut CGSize);
198}
199 
200const FOREGROUND_COLOR_KEY: &str = "foreground-color";
201const SYNTAX_COLOR_KEY: &str = "syntax-color";
202const BACKGROUND_COLOR_KEY: &str = "background-color";
203const ERROR_UNDERLINE_COLOR_KEY: &str = "error-underline-color";
204const BORDER_COLOR_KEY: &str = "border-color";
205const BORDER_WIDTH_KEY: &str = "border-width";
206const BORDER_RADIUS_KEY: &str = "border-radius";
207const BORDER_LINE_HEIGHT_RATIO_KEY: &str = "border-line-height-ratio";
208const SHOW_STRIKETHROUGH_KEY: &str = "show-strikethrough";
209const HYPERLINK_UNDERLINE_STYLE_KEY: &str = "hyperlink-underline-style";
210const HYPERLINK_ID: &str = "hyperlink-id";
211 
212fn text_style_as_cf_type_pairs(style: &TextStyle) -> Vec<(CFString, CFType)> {
213 let mut key_value_pairs = vec![];
214 if let Some(foreground_color) = style.foreground_color.as_ref() {
215 key_value_pairs.push((
216 CFString::new(FOREGROUND_COLOR_KEY),
217 color_u_to_cg_color(*foreground_color).as_CFType(),
218 ))
219 }
220 
221 if let Some(syntax_color) = style.syntax_color.as_ref() {
222 key_value_pairs.push((
223 CFString::new(SYNTAX_COLOR_KEY),
224 color_u_to_cg_color(*syntax_color).as_CFType(),
225 ))
226 }
227 
228 if let Some(background_color) = style.background_color.as_ref() {
229 key_value_pairs.push((
230 CFString::new(BACKGROUND_COLOR_KEY),
231 color_u_to_cg_color(*background_color).as_CFType(),
232 ))
233 }
234 
235 if let Some(error_underline_color) = style.error_underline_color.as_ref() {
236 key_value_pairs.push((
237 CFString::new(ERROR_UNDERLINE_COLOR_KEY),
238 color_u_to_cg_color(*error_underline_color).as_CFType(),
239 ))
240 }
241 
242 if let Some(border) = style.border.as_ref() {
243 key_value_pairs.push((
244 CFString::new(BORDER_COLOR_KEY),
245 color_u_to_cg_color(border.color).as_CFType(),
246 ));
247 
248 key_value_pairs.push((
249 CFString::new(BORDER_RADIUS_KEY),
250 CFNumber::from(border.radius as i32).as_CFType(),
251 ));
252 
253 key_value_pairs.push((
254 CFString::new(BORDER_WIDTH_KEY),
255 CFNumber::from(border.width as i32).as_CFType(),
256 ));
257 
258 if let Some(line_height_override) = border.line_height_ratio_override {
259 key_value_pairs.push((
260 CFString::new(BORDER_LINE_HEIGHT_RATIO_KEY),
261 CFNumber::from(line_height_override as i32).as_CFType(),
262 ));
263 }
264 }
265 
266 if let Some(underline_color) = style.underline_color.as_ref() {
267 key_value_pairs.push((
268 CFString::new(HYPERLINK_UNDERLINE_STYLE_KEY),
269 color_u_to_cg_color(*underline_color).as_CFType(),
270 ))
271 }
272 
273 if let Some(hyperlink_id) = style.hyperlink_id {
274 key_value_pairs.push((
275 CFString::new(HYPERLINK_ID),
276 CFNumber::from(hyperlink_id).as_CFType(),
277 ))
278 }
279 
280 key_value_pairs.push((
281 CFString::new(SHOW_STRIKETHROUGH_KEY),
282 CFBoolean::from(style.show_strikethrough).as_CFType(),
283 ));
284 
285 key_value_pairs
286}
287 
288fn attributes_to_text_style(attributes_dictionary: CFDictionary<CFString, CFType>) -> TextStyle {
289 let mut text_styles = TextStyle::new();
290 
291 if let Some(cg_color) = attributes_dictionary
292 .find(CFString::new(FOREGROUND_COLOR_KEY))
293 .and_then(|value| value.downcast::<CGColor>())
294 {
295 text_styles = text_styles.with_foreground_color(cg_color_to_color_u(cg_color));
296 }
297 
298 if let Some(cg_color) = attributes_dictionary
299 .find(CFString::new(SYNTAX_COLOR_KEY))
300 .and_then(|value| value.downcast::<CGColor>())
301 {
302 text_styles = text_styles.with_syntax_color(cg_color_to_color_u(cg_color));
303 }
304 
305 if let Some(cg_color) = attributes_dictionary
306 .find(CFString::new(BACKGROUND_COLOR_KEY))
307 .and_then(|value| value.downcast::<CGColor>())
308 {
309 text_styles = text_styles.with_background_color(cg_color_to_color_u(cg_color));
310 }
311 
312 let border_color = attributes_dictionary
313 .find(CFString::new(BORDER_COLOR_KEY))
314 .and_then(|value| value.downcast::<CGColor>());
315 let border_width = attributes_dictionary
316 .find(CFString::new(BORDER_WIDTH_KEY))
317 .and_then(|value| value.downcast::<CFNumber>())
318 .and_then(|num| num.to_i32());
319 let border_radius = attributes_dictionary
320 .find(CFString::new(BORDER_RADIUS_KEY))
321 .and_then(|value| value.downcast::<CFNumber>())
322 .and_then(|num| num.to_i32());
323 let border_line_height_ratio = attributes_dictionary
324 .find(CFString::new(BORDER_LINE_HEIGHT_RATIO_KEY))
325 .and_then(|value| value.downcast::<CFNumber>())
326 .and_then(|num| num.to_i32());
327 
328 if let Some(((color, width), radius)) = border_color.zip(border_width).zip(border_radius) {
329 text_styles = text_styles.with_border(TextBorder {
330 color: cg_color_to_color_u(color),
331 radius: radius as u8,
332 width: width as u8,
333 line_height_ratio_override: border_line_height_ratio.map(|val| val as u8),
334 });
335 }
336 
337 if let Some(cg_color) = attributes_dictionary
338 .find(CFString::new(ERROR_UNDERLINE_COLOR_KEY))
339 .and_then(|value| value.downcast::<CGColor>())
340 {
341 text_styles = text_styles.with_error_underline_color(cg_color_to_color_u(cg_color));
342 }
343 
344 if let Some(show_strikethrough) = attributes_dictionary
345 .find(CFString::new(SHOW_STRIKETHROUGH_KEY))
346 .and_then(|value| value.downcast::<CFBoolean>())
347 {
348 text_styles = text_styles.with_show_strikethrough(bool::from(show_strikethrough));
349 }
350 
351 if let Some(cg_color) = attributes_dictionary
352 .find(CFString::new(HYPERLINK_UNDERLINE_STYLE_KEY))
353 .and_then(|value| value.downcast::<CGColor>())
354 {
355 text_styles = text_styles.with_underline_color(cg_color_to_color_u(cg_color));
356 }
357 
358 if let Some(hyperlink_id) = attributes_dictionary
359 .find(CFString::new(HYPERLINK_ID))
360 .and_then(|value| value.downcast::<CFNumber>())
361 .and_then(|hyperlink| hyperlink.to_i32())
362 {
363 text_styles = text_styles.with_hyperlink_id(hyperlink_id);
364 }
365 
366 text_styles
367}
368 
369/// Lays out a *single* line using Core Text.
370pub fn layout_line(
371 text: &str,
372 line_style: LineStyle,
373 style_runs: &[(Range<usize>, StyleAndFont)],
374 font_db: &FontDB,
375 clip_config: ClipConfig,
376) -> Line {
377 layout_line_with_offset(text, line_style, style_runs, font_db, 0, clip_config)
378}
379 
380/// Lays out a line assuming the starting character index of the `text` starts at `char_offset`.
381fn layout_line_with_offset(
382 text: &str,
383 line_style: LineStyle,
384 style_runs: &[(Range<usize>, StyleAndFont)],
385 font_db: &FontDB,
386 char_offset: usize,
387 clip_config: ClipConfig,
388) -> Line {
389 if text.is_empty() {
390 Line::empty(
391 line_style.font_size,
392 line_style.line_height_ratio,
393 char_offset,
394 )
395 } else {
396 let attributed_string =
397 create_attributed_string(text, style_runs, font_db, line_style, None);
398 let line = CTLine::new_with_attributed_string(attributed_string.as_concrete_TypeRef());
399 let utf16_offset_to_char_idx = build_utf16_lookup(text);
400 line_from_ct_line(
401 line,
402 line_style,
403 font_db,
404 char_offset,
405 Some(clip_config),
406 &utf16_offset_to_char_idx,
407 )
408 }
409}
410 
411fn push_paragraph_style_settings(
412 paragraph_style_settings: &mut Vec<ParagraphStyleSetting>,
413 first_line_head_indent: Option<f32>,
414 tab_interval: Option<CGFloat>,
415) {
416 if let Some(first_line_head_indent_value) = first_line_head_indent {
417 paragraph_style_settings.push(ParagraphStyleSetting::new_float_setting(
418 CTParagraphStyleSpecifier::FirstLineHeadIndent,
419 first_line_head_indent_value as CGFloat,
420 ));
421 }
422 
423 if let Some(interval) = tab_interval {
424 // Core Text has a built-in list of tab stops. To ensure DefaultTabInterval applies from
425 // the first tab, we must also set an explicit (empty) TabStops array.
426 paragraph_style_settings.push(ParagraphStyleSetting::new_empty_tab_stops());
427 paragraph_style_settings.push(ParagraphStyleSetting::new_float_setting(
428 CTParagraphStyleSpecifier::DefaultTabInterval,
429 interval,
430 ));
431 }
432}
433 
434/// Applies paragraph style settings to an attributed string if the settings vec is non-empty.
435fn apply_paragraph_style_settings(
436 attributed_string: &mut CFMutableAttributedString,
437 cf_range: CFRange,
438 paragraph_style_settings: &[ParagraphStyleSetting],
439) {
440 if !paragraph_style_settings.is_empty() {
441 let ct_style_settings = paragraph_style_settings
442 .iter()
443 .map(|setting| setting.to_ct_setting())
444 .collect();
445 let paragraph_style = ParagraphStyle::new(ct_style_settings);
446 unsafe {
447 attributed_string.set_attribute(
448 cf_range,
449 kCTParagraphStyleAttributeName,
450 &paragraph_style.style,
451 );
452 }
453 }
454}
455 
456/// Creates a `CFAttributedString` out of `text` with the correct font ranges based on `runs`.
457fn create_attributed_string(
458 text: &str,
459 style_runs: &[(Range<usize>, StyleAndFont)],
460 font_db: &FontDB,
461 line_style: LineStyle,
462 first_line_head_indent: Option<f32>,
463) -> CFMutableAttributedString {
464 let mut attributed_string = CFMutableAttributedString::new();
465 attributed_string.replace_str(&CFString::new(text), CFRange::init(0, 0));
466 
467 // Wrap the edits to the `CFMutableAttributedString` with `BeginEditing` and `EndEditing` calls
468 // so that `CFMutableAttributedString` doesn't have to maintain consistency in between edits.
469 // See https://developer.apple.com/documentation/corefoundation/cfmutableattributedstring?language=objc.
470 unsafe {
471 CFAttributedStringBeginEditing(attributed_string.as_concrete_TypeRef());
472 }
473 
474 let mut utf16_lens = text.chars().map(|c| c.len_utf16());
475 let mut prev_char_ix: usize = 0;
476 let mut prev_utf16_ix: usize = 0;
477 
478 let tab_interval = line_style.fixed_width_tab_size.and_then(|tab_size| {
479 // In fully fixed-width paragraphs, style runs should all resolve to the same monospace font.
480 // Use the first run to pick a font for computing the tab interval.
481 let (_, style_and_font) = style_runs.iter().next()?;
482 let font_id = font_db.select_font(style_and_font.font_family, style_and_font.properties);
483 font_db
484 .space_advance_width(font_id, line_style.font_size)
485 .map(|w| (w * tab_size as f64) as CGFloat)
486 });
487 
488 // Apply paragraph-level settings (like tab stops) over the full string so they
489 // still apply even if style runs don't cover whitespace.
490 {
491 let mut paragraph_style_settings = vec![];
492 push_paragraph_style_settings(
493 &mut paragraph_style_settings,
494 first_line_head_indent,
495 tab_interval,
496 );
497 let full_range = CFRange::init(0, attributed_string.char_len());
498 apply_paragraph_style_settings(
499 &mut attributed_string,
500 full_range,
501 &paragraph_style_settings,
502 );
503 }
504 
505 for (range, style_and_font) in style_runs {
506 let utf16_start: usize = prev_utf16_ix
507 + utf16_lens
508 .by_ref()
509 .take(range.start - prev_char_ix)
510 .sum::<usize>();
511 let utf16_end: usize = utf16_start
512 + utf16_lens
513 .by_ref()
514 .take(range.end - range.start)
515 .sum::<usize>();
516 prev_char_ix = range.end;
517 prev_utf16_ix = utf16_end;
518 
519 let cf_range = CFRange::init(utf16_start as isize, (utf16_end - utf16_start) as isize);
520 let font_id = font_db.select_font(style_and_font.font_family, style_and_font.properties);
521 let native_font = font_db.native_font(font_id, line_style.font_size);
522 
523 let attributes_pairs = text_style_as_cf_type_pairs(&style_and_font.style);
524 unsafe {
525 attributed_string.set_attribute(cf_range, kCTFontAttributeName, &native_font);
526 
527 // The way the system computes line height can be slightly different from the way we compute line height.
528 // See https://www.zsiegel.com/2012/10/23/Core-Text-Calculating-line-heights for how the system computes line height.
529 // To make the system use the same line height that we do in the app, we use a multiplier.
530 // This is necessary to prevent the case where the system doesn't render any text because it thinks there isn't enough vertical space,
531 // but there is actually enough space based on the app line height.
532 let system_font_line_height =
533 native_font.ascent() + native_font.descent() + native_font.leading();
534 let app_font_line_height =
535 line_style.font_size as CGFloat * line_style.line_height_ratio as CGFloat;
536 let line_height_multiple = app_font_line_height / system_font_line_height;
537 let line_height_multiple_setting = ParagraphStyleSetting::new_float_setting(
538 CTParagraphStyleSpecifier::LineHeightMultiple,
539 line_height_multiple,
540 );
541 let mut paragraph_style_settings = vec![line_height_multiple_setting];
542 
543 // Note: we apply tab stops here *and* over the full string. We need both because we
544 // set a per-run paragraph style (to normalize line height), and that overrides the
545 // full-range paragraph style for this character range.
546 push_paragraph_style_settings(
547 &mut paragraph_style_settings,
548 first_line_head_indent,
549 tab_interval,
550 );
551 
552 apply_paragraph_style_settings(
553 &mut attributed_string,
554 cf_range,
555 &paragraph_style_settings,
556 );
557 
558 // When we apply inline code block, we need to leave additional spacing before and after the text to make sure
559 // we can render the block without overlapping with text outside of the code block. We add these spacing by setting
560 // the kerning attribute on the glyph right before the code block range and the last glyph in the block.
561 //
562 // For example if we want to highlight `bc` in abcd
563 // We would apply kerning on `a` and `c`. The text spacing would look like [a ][b][c ][d].
564 if style_and_font.style.border.is_some() {
565 if utf16_start > 0 {
566 let kerning_range = CFRange::init((utf16_start - 1) as isize, 1_isize);
567 attributed_string.set_attribute(
568 kerning_range,
569 kCTKernAttributeName,
570 &CFNumber::from(6.),
571 );
572 }
573 
574 if utf16_end > 0 {
575 let kerning_range = CFRange::init((utf16_end - 1) as isize, 1_isize);
576 attributed_string.set_attribute(
577 kerning_range,
578 kCTKernAttributeName,
579 &CFNumber::from(6.),
580 );
581 }
582 }
583 
584 for (key, value) in attributes_pairs {
585 attributed_string.set_attribute(cf_range, key.as_concrete_TypeRef(), &value);
586 }
587 }
588 }
589 
590 unsafe {
591 CFAttributedStringEndEditing(attributed_string.as_concrete_TypeRef());
592 }
593 attributed_string
594}
595 
596/// Lays out a string of text into a frame (a series of lines) using Core Text.
597#[allow(clippy::too_many_arguments)]
598pub fn layout_text(
599 text: &str,
600 line_style: LineStyle,
601 style_runs: &[(Range<usize>, StyleAndFont)],
602 font_db: &FontDB,
603 max_width: f32,
604 max_height: f32,
605 alignment: TextAlignment,
606 mut first_line_head_indent: Option<f32>,
607) -> TextFrame {
608 if text.is_empty() {
609 TextFrame::empty(line_style.font_size, line_style.line_height_ratio)
610 } else {
611 // Ensure the max height is finite--under certain conditions `CTFrameSetter` won't terminate
612 // if the height is unbounded.
613 let max_height = max_height.min(f32::MAX);
614 
615 let mut insert_extra_initial_line = false;
616 // Core Text always tries to put at least 1 character on the first line, which does not work well
617 // in the case of a large head indent which is >= the width of the first line. In this case, we
618 // handle the "empty" first line manually (and ask Core Text to lay out the rest of the lines).
619 if let Some(indent) = first_line_head_indent {
620 // We approximate the width of a single character to be half of the font size. This is to encourage
621 // wrapping the character as soon as it starts to get significantly clipped (due to the max width).
622 if indent >= max_width - (line_style.font_size / 2.) {
623 first_line_head_indent = None;
624 insert_extra_initial_line = true;
625 }
626 }
627 let attributed_string = create_attributed_string(
628 text,
629 style_runs,
630 font_db,
631 line_style,
632 first_line_head_indent,
633 );
634 let framesetter =
635 CTFramesetter::new_with_attributed_string(attributed_string.as_concrete_TypeRef());
636 
637 // Create frame from framesetter.
638 let cf_range = CFRange::init(0, attributed_string.char_len());
639 let frame = framesetter.create_frame(
640 cf_range,
641 CGPath::from_rect(
642 CGRect::new(
643 &CGPoint::new(0., 0.),
644 // Even though we use a multiplier for line height to account for differences between
645 // the system computed line height and our app computed line height, there are cases where
646 // the system still thinks more height is required. We add a small buffer to account for this
647 // because we should be able to render text when max_height is exactly the app computed line height.
648 &CGSize::new(max_width as f64, max_height as f64 + 2.),
649 ),
650 None,
651 )
652 .as_ref(),
653 );
654 let mut max_line_width: f32 = 0.;
655 
656 let mut frame_lines = vec![];
657 if insert_extra_initial_line {
658 // If the head indent was >= the width of the first line, we manually add back in the first empty line!
659 frame_lines.push(Line::empty(
660 line_style.font_size,
661 line_style.line_height_ratio,
662 0,
663 ));
664 }
665 
666 let lines = frame.get_lines();
667 let num_lines = lines.len();
668 
669 let utf16_offset_to_char_idx = build_utf16_lookup(text);
670 
671 frame_lines.append(
672 &mut lines
673 .into_iter()
674 .enumerate()
675 .map(|(index, line)| {
676 let (line_start_utf_16_index, line_length) = {
677 let range = line.get_string_range();
678 // The `CFRange` returned by CoreText returns a location that is the number of
679 // utf-16 bytes from the start of the string.
680 (range.location as usize, range.length as usize)
681 };
682 
683 // If the last line would be clipped, render the rest of the text into it's own line so
684 // that we can overflow the text by fading the text.
685 let is_last_line = index == num_lines - 1;
686 let line = if is_last_line
687 && line_start_utf_16_index + line_length
688 < attributed_string.char_len() as usize
689 {
690 // The string range returned by CoreText is in terms of the number of UTF-16 bytes,
691 // so iterate through the string to find the corresponding char index.
692 let star_char_index =
693 char_index_from_utf_16_byte_index(text, line_start_utf_16_index);
694 
695 // CoreText does not support multiline overflow when using `CTFrame`. To support
696 // multiline text that is clipped, we first use CoreText to render the text into a
697 // frame and then relayout the last line with the rest of string on that line.
698 // See https://groups.google.com/g/cocoa-unbound/c/Qin6gjYj7XU?pli=1 for more
699 // details.
700 let style_runs = style_runs
701 .iter()
702 .filter_map(|(range, font)| {
703 if range.end >= star_char_index {
704 Some((
705 Range {
706 start: range.start.saturating_sub(star_char_index),
707 end: range.end.saturating_sub(star_char_index),
708 },
709 *font,
710 ))
711 } else {
712 None
713 }
714 })
715 .collect::<Vec<_>>();
716 
717 let chars: Vec<_> = text.chars().collect();
718 layout_line_with_offset(
719 &chars[star_char_index..].iter().collect::<String>(),
720 line_style,
721 &style_runs,
722 font_db,
723 star_char_index,
724 ClipConfig::default(),
725 )
726 } else {
727 line_from_ct_line(
728 line,
729 line_style,
730 font_db,
731 0,
732 // Only apply clipping to the last line in the frame.
733 is_last_line.then_some(ClipConfig::default()),
734 &utf16_offset_to_char_idx,
735 )
736 };
737 
738 max_line_width = max_line_width.max(line.width);
739 line
740 })
741 .collect_vec(),
742 );
743 
744 match Vec1::try_from_vec(frame_lines) {
745 Ok(frame_lines) => TextFrame::new(frame_lines, max_line_width, alignment),
746 Err(_) => TextFrame::empty(line_style.font_size, line_style.line_height_ratio),
747 }
748 }
749}
750 
751fn line_from_ct_line(
752 line: CTLine,
753 line_style: LineStyle,
754 font_db: &FontDB,
755 char_offset: usize,
756 clip_config: Option<ClipConfig>,
757 utf16_offset_to_char_idx: &[usize],
758) -> Line {
759 let mut runs = Vec::with_capacity(line.glyph_runs().len() as usize);
760 let typographic_bounds = line.get_typographic_bounds();
761 let width = typographic_bounds.width as f32;
762 
763 let mut previous_run_font_and_attribute = None;
764 
765 for run in line.glyph_runs().into_iter() {
766 let attributes = run.attributes().expect("attributes should exist");
767 
768 let font_id = font_db.font_id_for_native_font(unsafe {
769 attributes
770 .get(kCTFontAttributeName)
771 .downcast::<CTFont>()
772 .unwrap()
773 });
774 
775 let run_advances = advances(&run);
776 let glyphs = itertools::multizip((
777 run.glyphs().iter(),
778 run.positions().iter(),
779 run.string_indices().iter(),
780 run_advances.iter(),
781 ))
782 .map(|(glyph_id, position, utf16_offset, advance)| {
783 let utf16_offset = usize::try_from(*utf16_offset).expect("Negative character offset");
784 let char_index = utf16_offset_to_char_idx
785 .get(utf16_offset)
786 .expect("mapping covers whole string");
787 
788 Glyph {
789 id: *glyph_id as GlyphId,
790 position_along_baseline: vec2f(position.x as f32, position.y as f32),
791 index: char_offset + char_index,
792 width: advance.width as f32,
793 }
794 })
795 .collect_vec();
796 
797 // Only separate out text runs with different attribute that we will use in the paint stage.
798 // For text attributes that don't matter in the paint stage (e.g. kerning), treat them as one
799 // text run.
800 let text_style = attributes_to_text_style(attributes);
801 let width = run_advances
802 .iter()
803 .map(|advance| advance.width as f32)
804 .sum();
805 match previous_run_font_and_attribute {
806 Some((prev_font_id, prev_attribute))
807 if prev_font_id == font_id && text_style == prev_attribute && !runs.is_empty() =>
808 {
809 let last_run: &mut Run =
810 runs.last_mut().expect("Already checked runs are not empty");
811 last_run.glyphs.extend(glyphs);
812 last_run.width += width;
813 }
814 _ => {
815 runs.push(Run {
816 font_id,
817 glyphs,
818 styles: text_style,
819 width,
820 });
821 
822 previous_run_font_and_attribute = Some((font_id, text_style));
823 }
824 }
825 }
826 
827 let caret_positions = caret_positions_for_line(&line, char_offset, utf16_offset_to_char_idx);
828 
829 Line {
830 width,
831 trailing_whitespace_width: trailing_whitespace_width_for_line(&line).max(0.) as f32,
832 runs,
833 font_size: line_style.font_size,
834 clip_config,
835 line_height_ratio: line_style.line_height_ratio,
836 baseline_ratio: line_style.baseline_ratio,
837 ascent: typographic_bounds.ascent as f32,
838 descent: typographic_bounds.descent as f32,
839 caret_positions,
840 // TODO(CORE-2004): If we want to support external font fallback on
841 // Mac, we need to populate this with the missing chars.
842 chars_with_missing_glyphs: vec![],
843 }
844}
845 
846/// Returns the char index within `text` given the number of UTF-16 bytes from the start of the
847/// string.
848fn char_index_from_utf_16_byte_index(text: &str, utf_16_index: usize) -> usize {
849 let mut start_index_utf_16 = 0;
850 let mut start_index = 0;
851 for (index, char) in text.chars().enumerate() {
852 if utf_16_index <= start_index_utf_16 {
853 start_index = index;
854 break;
855 }
856 
857 start_index_utf_16 += char.len_utf16();
858 }
859 start_index
860}
861 
862/// Builds a lookup table for finding a character's index within the input
863/// string given its position in UTF-16 code units.
864///
865/// This is necessary because the glyph order is not guaranteed to match the
866/// character order (e.g.: for RTL text), and because a single character may be
867/// represented by multiple UTF-16 code units.
868///
869/// In the output `Vec`, indices correspond to UTF-16 code unit offsets and values
870/// correspond to character indices.
871fn build_utf16_lookup(text: &str) -> Vec<usize> {
872 // Use the UTF-8 length as a starting estimate, since each ASCII character
873 // is represented by both 1 UTF-8 code point and 1 UTF-16 code point.
874 let mut table = Vec::with_capacity(text.len());
875 
876 for (char_index, ch) in text.chars().enumerate() {
877 // For each UTF-16 code unit in the character, add a mapping to the
878 // character's index. The UTF-16 position is implicitly tracked by the
879 // length of the lookup table.
880 for _ in 0..ch.len_utf16() {
881 table.push(char_index);
882 }
883 }
884 
885 table
886}
887 
888/// Extract caret positions from a Core Text line.
889fn caret_positions_for_line(
890 line: &CTLine,
891 char_offset: usize,
892 utf16_offset_to_char_idx: &[usize],
893) -> Vec<CaretPosition> {
894 #[derive(Debug)]
895 struct CaretEdge {
896 /// Index of the UTF-16 code point corresponding to this caret edge.
897 utf16_index: usize,
898 /// Whether this is a leading edge or a trailing edge.
899 /// For LTR text, the leading edge is the leftmost edge of the cluster,
900 /// and for RTL text, it is the rightmost edge.
901 leading_edge: bool,
902 /// The pixel offset of this edge from the start of the line.
903 pixel_offset: f64,
904 }
905 
906 let positions = Rc::new(RefCell::new(vec![]));
907 
908 // Core Text produces leading and trailing edges for each caret position in
909 // the line. For our purposes, we only need the leading edge for rendering
910 // the caret. However, we use both edges to find the character extent of
911 // each caret.
912 let block = {
913 let positions = positions.clone();
914 ConcreteBlock::new(move |offset, char_index: isize, leading_edge, _stop| {
915 positions.borrow_mut().push(CaretEdge {
916 utf16_index: char_index.try_into().expect("Negative UTF-16 offset"),
917 leading_edge,
918 pixel_offset: offset,
919 });
920 })
921 };
922 
923 // We have to use RcBlock to avoid a double-free, but that takes ownership
924 // of utf16_offset_to_char_idx
925 let block = block.copy();
926 unsafe {
927 CTLineEnumerateCaretOffsets(line.as_concrete_TypeRef(), &block);
928 }
929 drop(block);
930 
931 let mut positions = Rc::try_unwrap(positions)
932 .expect("Block reference should be dropped")
933 .into_inner();
934 
935 debug_assert!(
936 positions.len() % 2 == 0,
937 "Missing a leading or trailing caret edge"
938 );
939 // Core Text sometimes swaps the order of the trailing edge of one caret and
940 // the leading edge of the next, causing edge pairs to be interspersed. So
941 // that we can pair the leading and trailing edges into carets, sort by
942 // character index, assuming that carets don't overlap with each other.
943 // positions should already be almost entirely sorted, except for swapped
944 // edges and RTL text.
945 positions.sort_unstable_by_key(|position| position.utf16_index);
946 let mut carets: Vec<_> = positions
947 .chunks_exact(2)
948 .map(|edges| {
949 // Guaranteed by chunks_exact that there are 2 elements.
950 let first = &edges[0];
951 let second = &edges[1];
952 
953 // Core Text enumerates edges in left-to-right visual order, but
954 // sets the leading edge based on logical order, so use that to
955 // handle RTL text.
956 // For any given leading/trailing edge pair, the pixel offset from
957 // the start of the line is the one for the leading edge.
958 let pixel_offset = if first.leading_edge {
959 first.pixel_offset
960 } else {
961 debug_assert!(
962 second.leading_edge,
963 "No leading edge in {first:?} or {second:?}"
964 );
965 second.pixel_offset
966 };
967 
968 let first_index = utf16_offset_to_char_idx
969 .get(first.utf16_index)
970 .expect("Mapping covers whole string")
971 + char_offset;
972 let second_index = utf16_offset_to_char_idx
973 .get(second.utf16_index)
974 .expect("Mapping covers whole string")
975 + char_offset;
976 
977 let (start, end) = if first_index < second_index {
978 (first_index, second_index)
979 } else {
980 (second_index, first_index)
981 };
982 
983 CaretPosition {
984 position_in_line: pixel_offset as f32,
985 start_offset: start,
986 last_offset: end,
987 }
988 })
989 .collect();
990 // Callers assume that carets are sorted by left-to-right visual display order,
991 // so sort back here.
992 carets.sort_unstable_by_key(|caret| OrderedFloat(caret.position_in_line));
993 carets
994}
995 
996fn trailing_whitespace_width_for_line(line: &CTLine) -> f64 {
997 unsafe { CTLineGetTrailingWhitespaceWidth(line.as_concrete_TypeRef()) }
998}
999 
1000/// Returns the advances for each glyph in the run.
1001///
1002/// If the run has a non-null advances pointer, returns a slice of the advances.
1003/// Otherwise, returns a vector of advances computed by calling
1004/// [CTRunGetAdvances](https://developer.apple.com/documentation/coretext/1508691-ctrungetadvances?language=objc).
1005///
1006/// This follows the same pattern as the glyph IDs and positions functions provided by
1007/// the `core-text` crate.
1008fn advances(run: &CTRun) -> Cow<'_, [CGSize]> {
1009 unsafe {
1010 let run_ref = run.as_concrete_TypeRef();
1011 // CTRunGetAdvancesPtr can return null under some not understood circumstances.
1012 // If it does the Apple documentation tells us to allocate our own buffer and call
1013 // CTRunGetAdvances
1014 let count = CTRunGetGlyphCount(run_ref);
1015 let advances_ptr = CTRunGetAdvancesPtr(run_ref);
1016 if !advances_ptr.is_null() {
1017 Cow::from(slice::from_raw_parts(advances_ptr, count as usize))
1018 } else {
1019 let mut vec = Vec::with_capacity(count as usize);
1020 // "If the length of the range is set to 0, then the copy operation will continue
1021 // from the start index of the range to the end of the run"
1022 CTRunGetAdvances(run_ref, CFRange::init(0, 0), vec.as_mut_ptr());
1023 vec.set_len(count as usize);
1024 Cow::from(vec)
1025 }
1026 }
1027}
1028 
1029#[cfg(test)]
1030#[path = "text_layout_test.rs"]
1031mod tests;
1032