piet_coregraphics/
text.rs

1// Copyright 2020 the Piet Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Text related stuff for the coregraphics backend
5
6use std::cell::RefCell;
7use std::fmt;
8use std::hash::{Hash, Hasher};
9use std::ops::{DerefMut, Range, RangeBounds};
10use std::rc::Rc;
11
12use associative_cache::{AssociativeCache, Capacity64, HashFourWay, RoundRobinReplacement};
13use core_foundation::base::TCFType;
14use core_foundation::dictionary::{CFDictionary, CFMutableDictionary};
15use core_foundation::number::CFNumber;
16use core_foundation::string::CFString;
17use core_foundation_sys::base::CFRange;
18use core_graphics::base::CGFloat;
19use core_graphics::context::CGContextRef;
20use core_graphics::geometry::{CGPoint, CGRect, CGSize};
21use core_graphics::path::CGPath;
22use core_text::{
23    font,
24    font::CTFont,
25    font_descriptor::{self, SymbolicTraitAccessors},
26    string_attributes,
27};
28
29use piet::kurbo::{Affine, Point, Rect, Size};
30use piet::{
31    util, Error, FontFamily, FontStyle, FontWeight, HitTestPoint, HitTestPosition, LineMetric,
32    Text, TextAlignment, TextAttribute, TextLayout, TextLayoutBuilder, TextStorage,
33};
34
35use crate::ct_helpers::{self, AttributedString, FontCollection, Frame, Framesetter, Line};
36
37/// both infinity and f64::MAX produce unpleasant results
38const MAX_LAYOUT_CONSTRAINT: f64 = 1e9;
39
40#[derive(Clone)]
41pub struct CoreGraphicsText {
42    shared: SharedTextState,
43}
44
45/// State shared by all `CoreGraphicsText` objects.
46///
47/// This is for holding onto expensive to create objects, and for things
48/// like caching fonts.
49#[derive(Clone)]
50struct SharedTextState {
51    inner: Rc<RefCell<TextState>>,
52}
53
54type Cache<K, V> = AssociativeCache<K, V, Capacity64, HashFourWay, RoundRobinReplacement>;
55
56struct TextState {
57    collection: FontCollection,
58    family_cache: Cache<String, Option<FontFamily>>,
59    font_cache: Cache<CoreTextFontKey, CTFont>,
60}
61
62#[derive(Clone)]
63pub struct CoreGraphicsTextLayout {
64    text: Rc<dyn TextStorage>,
65    attr_string: AttributedString,
66    framesetter: Framesetter,
67    pub(crate) frame: Option<Frame>,
68    /// The size of our layout as understood by coretext
69    pub(crate) frame_size: Size,
70    /// Extra height that is not part of our coretext frame. This can be from
71    /// one of two things: either the height of an empty layout, or the height
72    /// of the implied extra line when the layout ends in a newline.
73    bonus_height: f64,
74    image_bounds: Rect,
75    width_constraint: f64,
76    // these two are stored values we use to determine cursor extents when the layout is empty.
77    default_baseline: f64,
78    default_line_height: f64,
79    line_metrics: Rc<[LineMetric]>,
80    x_offsets: Rc<[f64]>,
81    trailing_ws_width: f64,
82}
83
84/// Building text layouts for `CoreGraphics`.
85pub struct CoreGraphicsTextLayoutBuilder {
86    width: f64,
87    alignment: TextAlignment,
88    text: Rc<dyn TextStorage>,
89    /// the end bound up to which we have already added attrs to our AttributedString
90    last_resolved_pos: usize,
91    last_resolved_utf16: usize,
92    attr_string: AttributedString,
93    /// We set default attributes once on the underlying attributed string;
94    /// this happens either when the first range attribute is added, or when
95    /// we build the string.
96    has_set_default_attrs: bool,
97    default_baseline: f64,
98    default_line_height: f64,
99    attrs: Attributes,
100    shared: SharedTextState,
101}
102
103/// A helper type for storing and resolving attributes
104#[derive(Default)]
105struct Attributes {
106    defaults: util::LayoutDefaults,
107    font: Option<Span<FontFamily>>,
108    size: Option<Span<f64>>,
109    weight: Option<Span<FontWeight>>,
110    style: Option<Span<FontStyle>>,
111}
112
113#[derive(Clone)]
114struct CoreTextFontKey {
115    font: FontFamily,
116    weight: FontWeight,
117    italic: bool,
118    size: f64,
119}
120
121impl PartialEq for CoreTextFontKey {
122    fn eq(&self, other: &CoreTextFontKey) -> bool {
123        self.font == other.font
124            && self.weight == other.weight
125            && self.italic == other.italic
126            && self.size.to_bits() == other.size.to_bits()
127    }
128}
129
130impl Eq for CoreTextFontKey {}
131
132impl Hash for CoreTextFontKey {
133    fn hash<H: Hasher>(&self, state: &mut H) {
134        self.font.hash(state);
135        self.weight.hash(state);
136        self.italic.hash(state);
137        self.size.to_bits().hash(state);
138    }
139}
140
141impl CoreTextFontKey {
142    fn create_ct_font(&self) -> CTFont {
143        // 'wght' as an int
144        const WEIGHT_AXIS_TAG: i32 = make_opentype_tag("wght") as i32;
145        // taken from android:
146        // https://api.skia.org/classSkFont.html#aa85258b584e9c693d54a8624e0fe1a15
147        const SLANT_TANGENT: f64 = 0.25;
148
149        unsafe {
150            let family_key =
151                CFString::wrap_under_create_rule(font_descriptor::kCTFontFamilyNameAttribute);
152            let family_name = ct_helpers::ct_family_name(&self.font, self.size);
153            let weight_key = CFString::wrap_under_create_rule(font_descriptor::kCTFontWeightTrait);
154            let weight = convert_to_coretext(self.weight);
155
156            let traits_key =
157                CFString::wrap_under_create_rule(font_descriptor::kCTFontTraitsAttribute);
158            let mut traits = CFMutableDictionary::new();
159            traits.set(weight_key, weight.as_CFType());
160            if self.italic {
161                let symbolic_traits_key =
162                    CFString::wrap_under_create_rule(font_descriptor::kCTFontSymbolicTrait);
163                let symbolic_traits = CFNumber::from(font_descriptor::kCTFontItalicTrait as i32);
164                traits.set(symbolic_traits_key, symbolic_traits.as_CFType());
165            }
166
167            let attributes = CFDictionary::from_CFType_pairs(&[
168                (family_key, family_name.as_CFType()),
169                (traits_key, traits.as_CFType()),
170            ]);
171            let descriptor = font_descriptor::new_from_attributes(&attributes);
172            let font = font::new_from_descriptor(&descriptor, self.size);
173
174            let needs_synthetic_ital = self.italic && !font.symbolic_traits().is_italic();
175            let has_var_axes = font.get_variation_axes().is_some();
176
177            if !(needs_synthetic_ital | has_var_axes) {
178                return font;
179            }
180
181            let affine = if needs_synthetic_ital {
182                Affine::new([1.0, 0.0, SLANT_TANGENT, 1.0, 0., 0.])
183            } else {
184                Affine::default()
185            };
186
187            let variation_axes = font
188                .get_variation_axes()
189                .map(|axes| {
190                    axes.iter()
191                        .flat_map(|dict| {
192                            // for debugging, this is how you get the name for the axis
193                            //let name = dict.find(ct_helpers::kCTFontVariationAxisNameKey).and_then(|v| v.downcast::<CFString>());
194                            dict.find(ct_helpers::kCTFontVariationAxisIdentifierKey)
195                                .and_then(|v| v.downcast::<CFNumber>().and_then(|num| num.to_i32()))
196                        })
197                        .collect::<Vec<_>>()
198                })
199                .unwrap_or_default();
200
201            // only set weight axis if it exists, and we're not a system font (things get weird)
202            let descriptor = if variation_axes.contains(&WEIGHT_AXIS_TAG) && !self.font.is_generic()
203            {
204                let weight_axis_id: CFNumber = WEIGHT_AXIS_TAG.into();
205                let descriptor = font_descriptor::CTFontDescriptorCreateCopyWithVariation(
206                    descriptor.as_concrete_TypeRef(),
207                    weight_axis_id.as_concrete_TypeRef(),
208                    self.weight.to_raw() as _,
209                );
210                font_descriptor::CTFontDescriptor::wrap_under_create_rule(descriptor)
211            } else {
212                descriptor
213            };
214
215            ct_helpers::make_font(&descriptor, self.size, affine)
216        }
217    }
218}
219
220/// during construction, `Span`s represent font attributes that have been applied
221/// to ranges of the text; these are combined into coretext font objects as the
222/// layout is built.
223struct Span<T> {
224    payload: T,
225    range: Range<usize>,
226}
227
228impl<T> Span<T> {
229    fn new(payload: T, range: Range<usize>) -> Self {
230        Span { payload, range }
231    }
232
233    fn range_end(&self) -> usize {
234        self.range.end
235    }
236}
237
238impl CoreGraphicsTextLayoutBuilder {
239    /// ## Note
240    ///
241    /// The implementation of this has a few particularities.
242    ///
243    /// The main Foundation type for representing a rich text string is NSAttributedString
244    /// (CFAttributedString in CoreFoundation); however not all attributes are set
245    /// directly. Attributes that implicate font selection (such as size, weight, etc)
246    /// are all part of the string's 'font' attribute; we can't set them individually.
247    ///
248    /// To make this work, we keep track of the active value for each of the relevant
249    /// attributes. Each span of the string with a common set of these values is assigned
250    /// the appropriate concrete font as the attributes are added.
251    ///
252    /// This behaviour relies on the condition that spans are added in non-decreasing
253    /// start order. The algorithm is quite simple; whenever a new attribute of one
254    /// of the relevant types is added, we know that spans in the string up to
255    /// the start of the newly added span can no longer be changed, and we can resolve them.
256    fn add(&mut self, attr: TextAttribute, range: Range<usize>) {
257        if !self.has_set_default_attrs {
258            self.set_default_attrs();
259        }
260        // Some attributes are 'standalone' and can just be added to the attributed string
261        // immediately.
262        if matches!(
263            &attr,
264            TextAttribute::TextColor(_) | TextAttribute::Underline(_)
265        ) {
266            return self.add_immediately(attr, range);
267        }
268
269        debug_assert!(
270            range.start >= self.last_resolved_pos,
271            "attributes must be added with non-decreasing start positions"
272        );
273
274        self.resolve_up_to(range.start);
275        // Other attributes need to be handled incrementally, since they all participate
276        // in creating the CTFont objects
277        self.attrs.add(range, attr);
278    }
279
280    fn set_default_attrs(&mut self) {
281        self.has_set_default_attrs = true;
282        let whole_range = self.attr_string.range();
283        let font = self.current_font();
284        let height = compute_line_height(font.ascent(), font.descent(), font.leading());
285        self.default_line_height = height;
286        self.default_baseline = (font.ascent() + 0.5).floor();
287        self.attr_string.set_font(whole_range, &font);
288        self.attr_string
289            .set_fg_color(whole_range, self.attrs.defaults.fg_color);
290        self.attr_string
291            .set_underline(whole_range, self.attrs.defaults.underline);
292    }
293
294    fn add_immediately(&mut self, attr: TextAttribute, range: Range<usize>) {
295        let utf16_start = util::count_utf16(&self.text[..range.start]);
296        let utf16_len = util::count_utf16(&self.text[range]);
297        let range = CFRange::init(utf16_start as isize, utf16_len as isize);
298        match attr {
299            TextAttribute::TextColor(color) => {
300                self.attr_string.set_fg_color(range, color);
301            }
302            TextAttribute::Underline(flag) => self.attr_string.set_underline(range, flag),
303            _ => unreachable!(),
304        }
305    }
306
307    fn finalize(&mut self) {
308        if !self.has_set_default_attrs {
309            self.set_default_attrs();
310        }
311        self.resolve_up_to(self.text.len());
312    }
313
314    /// Add all font attributes up to a boundary.
315    fn resolve_up_to(&mut self, resolve_end: usize) {
316        let mut next_span_end = self.last_resolved_pos;
317        while next_span_end < resolve_end {
318            next_span_end = self.next_span_end(resolve_end);
319            if next_span_end > self.last_resolved_pos {
320                let range_end_utf16 =
321                    util::count_utf16(&self.text[self.last_resolved_pos..next_span_end]);
322                let range =
323                    CFRange::init(self.last_resolved_utf16 as isize, range_end_utf16 as isize);
324                let font = self.current_font();
325                unsafe {
326                    self.attr_string.inner.set_attribute(
327                        range,
328                        string_attributes::kCTFontAttributeName,
329                        &font,
330                    );
331                }
332                self.last_resolved_pos = next_span_end;
333                self.last_resolved_utf16 += range_end_utf16;
334                self.update_after_adding_span();
335            }
336        }
337    }
338
339    /// Given the end of a range, return the min of that value and the ends of
340    /// any existing spans.
341    ///
342    /// ## Invariant
343    ///
344    /// It is an invariant that the end range of any `FontAttr` is greater than
345    /// `self.last_resolved_pos`
346    fn next_span_end(&self, max: usize) -> usize {
347        self.attrs.next_span_end(max)
348    }
349
350    /// Returns the fully constructed font object, including weight and size.
351    ///
352    /// This is stateful; it depends on the current attributes being correct
353    /// for the range that begins at `self.last_resolved_pos`.
354    fn current_font(&self) -> CTFont {
355        self.shared.get_ct_font(&CoreTextFontKey {
356            font: self.attrs.font().to_owned(),
357            weight: self.attrs.weight(),
358            italic: self.attrs.italic(),
359            size: self.attrs.size(),
360        })
361    }
362
363    /// After we have added a span, check to see if any of our attributes are no
364    /// longer active.
365    ///
366    /// This is stateful; it requires that `self.last_resolved_pos` has been just updated
367    /// to reflect the end of the span just added.
368    fn update_after_adding_span(&mut self) {
369        self.attrs.clear_up_to(self.last_resolved_pos)
370    }
371}
372
373impl Attributes {
374    fn add(&mut self, range: Range<usize>, attr: TextAttribute) {
375        match attr {
376            TextAttribute::FontFamily(font) => self.font = Some(Span::new(font, range)),
377            TextAttribute::Weight(w) => self.weight = Some(Span::new(w, range)),
378            TextAttribute::FontSize(s) => self.size = Some(Span::new(s, range)),
379            TextAttribute::Style(s) => self.style = Some(Span::new(s, range)),
380            TextAttribute::Strikethrough(_) => { /* Unimplemented for now as coregraphics doesn't have native strikethrough support. */
381            }
382            _ => unreachable!(),
383        }
384    }
385
386    fn size(&self) -> f64 {
387        self.size
388            .as_ref()
389            .map(|s| s.payload)
390            .unwrap_or(self.defaults.font_size)
391    }
392
393    fn weight(&self) -> FontWeight {
394        self.weight
395            .as_ref()
396            .map(|w| w.payload)
397            .unwrap_or(self.defaults.weight)
398    }
399
400    fn italic(&self) -> bool {
401        matches!(
402            self.style
403                .as_ref()
404                .map(|t| t.payload)
405                .unwrap_or(self.defaults.style),
406            FontStyle::Italic
407        )
408    }
409
410    fn font(&self) -> &FontFamily {
411        self.font
412            .as_ref()
413            .map(|t| &t.payload)
414            .unwrap_or_else(|| &self.defaults.font)
415    }
416
417    fn next_span_end(&self, max: usize) -> usize {
418        self.font
419            .as_ref()
420            .map(Span::range_end)
421            .unwrap_or(max)
422            .min(self.size.as_ref().map(Span::range_end).unwrap_or(max))
423            .min(self.weight.as_ref().map(Span::range_end).unwrap_or(max))
424            .min(self.style.as_ref().map(Span::range_end).unwrap_or(max))
425            .min(max)
426    }
427
428    // invariant: `last_pos` is the end of at least one span.
429    fn clear_up_to(&mut self, last_pos: usize) {
430        if self.font.as_ref().map(Span::range_end) == Some(last_pos) {
431            self.font = None;
432        }
433        if self.weight.as_ref().map(Span::range_end) == Some(last_pos) {
434            self.weight = None;
435        }
436        if self.style.as_ref().map(Span::range_end) == Some(last_pos) {
437            self.style = None;
438        }
439        if self.size.as_ref().map(Span::range_end) == Some(last_pos) {
440            self.size = None;
441        }
442    }
443}
444
445/// coretext uses a float in the range -1.0..=1.0, which has a non-linear mapping
446/// to css-style weights. This is a fudge, adapted from QT:
447///
448/// <https://git.sailfishos.org/mer-core/qtbase/commit/9ba296cc4cefaeb9d6c5abc2e0c0b272f2288733#1b84d1913347bd20dd0a134247f8cd012a646261_44_55>
449//TODO: a better solution would be piecewise linear interpolation between these values
450fn convert_to_coretext(weight: FontWeight) -> CFNumber {
451    match weight.to_raw() {
452        0..=199 => -0.8,
453        200..=299 => -0.6,
454        300..=399 => -0.4,
455        400..=499 => 0.0,
456        500..=599 => 0.23,
457        600..=699 => 0.3,
458        700..=799 => 0.4,
459        800..=899 => 0.56,
460        _ => 0.62,
461    }
462    .into()
463}
464
465impl CoreGraphicsText {
466    /// Create a new factory that satisfies the piet `Text` trait.
467    ///
468    /// The returned type will have freshly initiated inner state; this means
469    /// it will not share a cache with any other objects created with this method.
470    ///
471    /// In general this should be created once and then cloned and passed around.
472    pub fn new_with_unique_state() -> CoreGraphicsText {
473        let collection = FontCollection::new_with_all_fonts();
474        let inner = Rc::new(RefCell::new(TextState {
475            collection,
476            family_cache: Default::default(),
477            font_cache: Default::default(),
478        }));
479        CoreGraphicsText {
480            shared: SharedTextState { inner },
481        }
482    }
483}
484
485impl fmt::Debug for CoreGraphicsText {
486    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
487        f.debug_struct("CoreGraphicsText").finish()
488    }
489}
490
491impl Text for CoreGraphicsText {
492    type TextLayout = CoreGraphicsTextLayout;
493    type TextLayoutBuilder = CoreGraphicsTextLayoutBuilder;
494
495    fn font_family(&mut self, family_name: &str) -> Option<FontFamily> {
496        self.shared.get_font_family(family_name)
497    }
498
499    fn new_text_layout(&mut self, text: impl TextStorage) -> Self::TextLayoutBuilder {
500        CoreGraphicsTextLayoutBuilder::new(text, self.shared.clone())
501    }
502
503    fn load_font(&mut self, data: &[u8]) -> Result<FontFamily, Error> {
504        ct_helpers::add_font(data)
505            .map(FontFamily::new_unchecked)
506            .map_err(|_| Error::MissingFont)
507    }
508}
509
510impl SharedTextState {
511    /// Return the family object for this family name, if it exists.
512    ///
513    /// This hits a cache before doing a lookup with the system.
514    fn get_font_family(&self, family_name: &str) -> Option<FontFamily> {
515        let mut inner = self.inner.borrow_mut();
516        let obj = inner.deref_mut();
517        let family_cache = &mut obj.family_cache;
518        let collection = &mut obj.collection;
519        family_cache
520            .entry(family_name)
521            .or_insert_with(
522                || family_name.to_owned(),
523                || collection.font_for_family_name(family_name),
524            )
525            .clone()
526    }
527
528    /// Return a CTFont handle for this key (combination of font and the attributes).
529    ///
530    /// This hits a cache before creating the CTFont.
531    fn get_ct_font(&self, key: &CoreTextFontKey) -> CTFont {
532        let mut inner = self.inner.borrow_mut();
533        inner
534            .font_cache
535            .entry(key)
536            .or_insert_with(|| key.to_owned(), || key.create_ct_font())
537            .clone()
538    }
539}
540
541impl CoreGraphicsTextLayoutBuilder {
542    fn new(text: impl TextStorage, shared: SharedTextState) -> Self {
543        let text = Rc::new(text);
544        let attr_string = AttributedString::new(text.as_str());
545        CoreGraphicsTextLayoutBuilder {
546            shared,
547            width: MAX_LAYOUT_CONSTRAINT,
548            alignment: TextAlignment::default(),
549            attrs: Default::default(),
550            text,
551            last_resolved_pos: 0,
552            last_resolved_utf16: 0,
553            attr_string,
554            has_set_default_attrs: false,
555            default_baseline: 0.0,
556            default_line_height: 0.0,
557        }
558    }
559}
560
561impl fmt::Debug for CoreGraphicsTextLayoutBuilder {
562    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
563        f.debug_struct("CoreGraphicsTextLayoutBuilder").finish()
564    }
565}
566
567impl TextLayoutBuilder for CoreGraphicsTextLayoutBuilder {
568    type Out = CoreGraphicsTextLayout;
569
570    fn max_width(mut self, width: f64) -> Self {
571        self.width = width;
572        self
573    }
574
575    fn alignment(mut self, alignment: piet::TextAlignment) -> Self {
576        self.alignment = alignment;
577        self
578    }
579
580    fn default_attribute(mut self, attribute: impl Into<TextAttribute>) -> Self {
581        debug_assert!(
582            !self.has_set_default_attrs,
583            "default attributes mut be added before range attributes"
584        );
585        let attribute = attribute.into();
586        self.attrs.defaults.set(attribute);
587        self
588    }
589
590    fn range_attribute(
591        mut self,
592        range: impl RangeBounds<usize>,
593        attribute: impl Into<TextAttribute>,
594    ) -> Self {
595        let range = util::resolve_range(range, self.text.len());
596        let attribute = attribute.into();
597        self.add(attribute, range);
598        self
599    }
600
601    fn build(mut self) -> Result<Self::Out, Error> {
602        self.finalize();
603        self.attr_string.set_alignment(self.alignment);
604        Ok(CoreGraphicsTextLayout::new(
605            self.text,
606            self.attr_string,
607            self.width,
608            self.default_baseline,
609            self.default_line_height,
610        ))
611    }
612}
613
614impl fmt::Debug for CoreGraphicsTextLayout {
615    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
616        f.debug_struct("CoreGraphicsTextLayout").finish()
617    }
618}
619
620impl TextLayout for CoreGraphicsTextLayout {
621    fn size(&self) -> Size {
622        Size::new(
623            self.frame_size.width,
624            self.frame_size.height + self.bonus_height,
625        )
626    }
627
628    fn trailing_whitespace_width(&self) -> f64 {
629        self.trailing_ws_width
630    }
631
632    fn image_bounds(&self) -> Rect {
633        self.image_bounds
634    }
635
636    fn text(&self) -> &str {
637        &self.text
638    }
639
640    fn line_text(&self, line_number: usize) -> Option<&str> {
641        self.line_range(line_number)
642            .map(|(start, end)| unsafe { self.text.get_unchecked(start..end) })
643    }
644
645    fn line_metric(&self, line_number: usize) -> Option<LineMetric> {
646        self.line_metrics.get(line_number).cloned()
647    }
648
649    fn line_count(&self) -> usize {
650        self.line_metrics.len()
651    }
652
653    // given a point on the screen, return an offset in the text, basically
654    fn hit_test_point(&self, point: Point) -> HitTestPoint {
655        let line_num = self
656            .line_metrics
657            .iter()
658            .position(|lm| lm.y_offset + lm.height >= point.y)
659            // if we're past the last line, use the last line
660            .unwrap_or_else(|| self.line_metrics.len().saturating_sub(1));
661
662        let line = match self.unwrap_frame().get_line(line_num) {
663            Some(line) => line,
664            None => {
665                // if we can't find a line we're either an empty string or we're
666                // at the newline at eof
667                assert!(self.text.is_empty() || util::trailing_nlf(&self.text).is_some());
668                return HitTestPoint::new(self.text.len(), false);
669            }
670        };
671        let line_text = self.line_text(line_num).unwrap();
672        let metric = &self.line_metrics[line_num];
673        let x_offset = self.x_offsets[line_num];
674        // a y position inside this line
675        let fake_y = metric.y_offset + metric.baseline;
676        // map that back into our inverted coordinate space
677        let fake_y = -(self.frame_size.height - fake_y);
678        let point_in_string_space = CGPoint::new(point.x - x_offset, fake_y);
679        let offset_utf16 = line.get_string_index_for_position(point_in_string_space);
680        let mut offset = match offset_utf16 {
681            // this is 'kCFNotFound'.
682            -1 => self.text.len(),
683            n if n >= 0 => {
684                let utf16_range = line.get_string_range();
685                let rel_offset = (n - utf16_range.location) as usize;
686                metric.start_offset
687                    + util::count_until_utf16(line_text, rel_offset).unwrap_or(line_text.len())
688            }
689            // some other value; should never happen
690            _ => panic!("gross violation of api contract"),
691        };
692
693        // if the offset is EOL && EOL is a newline, return the preceding offset
694        if offset == metric.end_offset {
695            offset -= util::trailing_nlf(line_text).unwrap_or(0);
696        };
697
698        let typo_bounds = line.get_typographic_bounds();
699        let is_inside_y = point.y >= 0. && point.y <= self.frame_size.height;
700        let is_inside_x =
701            point_in_string_space.x >= 0. && point_in_string_space.x <= typo_bounds.width;
702        let is_inside = is_inside_x && is_inside_y;
703
704        HitTestPoint::new(offset, is_inside)
705    }
706
707    fn hit_test_text_position(&self, idx: usize) -> HitTestPosition {
708        let idx = idx.min(self.text.len());
709        assert!(self.text.is_char_boundary(idx));
710
711        let line_num = self.line_number_for_utf8_offset(idx);
712        let line = match self.unwrap_frame().get_line(line_num) {
713            Some(line) => line,
714            None => {
715                assert!(self.text.is_empty() || util::trailing_nlf(&self.text).is_some());
716                let lm = &self.line_metrics[line_num];
717                let y_pos = lm.y_offset + lm.baseline;
718                return HitTestPosition::new(Point::new(0., y_pos), line_num);
719            }
720        };
721
722        let text = self.line_text(line_num).unwrap();
723        let metric = &self.line_metrics[line_num];
724        let x_offset = self.x_offsets[line_num];
725
726        let offset_remainder = idx - metric.start_offset;
727        let off16: usize = util::count_utf16(&text[..offset_remainder]);
728        let line_range = line.get_string_range();
729        let char_idx = line_range.location + off16 as isize;
730        let x_pos = line.get_offset_for_string_index(char_idx) + x_offset;
731        let y_pos = metric.y_offset + metric.baseline;
732        HitTestPosition::new(Point::new(x_pos, y_pos), line_num)
733    }
734}
735
736impl CoreGraphicsTextLayout {
737    fn new(
738        text: Rc<dyn TextStorage>,
739        attr_string: AttributedString,
740        width_constraint: f64,
741        default_baseline: f64,
742        default_line_height: f64,
743    ) -> Self {
744        let framesetter = Framesetter::new(&attr_string);
745
746        let mut layout = CoreGraphicsTextLayout {
747            text,
748            attr_string,
749            framesetter,
750            // all of this is correctly set in `update_width` below
751            frame: None,
752            frame_size: Size::ZERO,
753            bonus_height: 0.0,
754            image_bounds: Rect::ZERO,
755            // NaN to ensure we always execute code in update_width
756            width_constraint: f64::NAN,
757            default_baseline,
758            default_line_height,
759            line_metrics: Rc::new([]),
760            x_offsets: Rc::new([]),
761            trailing_ws_width: 0.0,
762        };
763        layout.update_width(width_constraint);
764        layout
765    }
766
767    // this used to be part of the TextLayout trait; see https://github.com/linebender/piet/issues/298
768    #[allow(clippy::float_cmp)]
769    fn update_width(&mut self, new_width: impl Into<Option<f64>>) {
770        let width = new_width.into().unwrap_or(MAX_LAYOUT_CONSTRAINT);
771        let width = if width.is_normal() {
772            width
773        } else {
774            MAX_LAYOUT_CONSTRAINT
775        };
776
777        if width.ceil() == self.width_constraint.ceil() {
778            return;
779        }
780
781        let constraints = CGSize::new(width as CGFloat, MAX_LAYOUT_CONSTRAINT);
782        let char_range = self.attr_string.range();
783        let rect = CGRect::new(&CGPoint::new(0.0, 0.0), &constraints);
784        let path = CGPath::from_rect(rect, None);
785        self.width_constraint = width;
786
787        let frame = self.framesetter.create_frame(char_range, &path);
788        let layout_metrics = build_line_metrics(
789            &frame,
790            &self.text,
791            self.default_line_height,
792            self.default_baseline,
793        );
794        self.line_metrics = layout_metrics.line_metrics.into();
795        self.x_offsets = layout_metrics.x_offsets.into();
796        self.trailing_ws_width = layout_metrics.trailing_whitespace;
797        self.frame_size = layout_metrics.layout_size;
798        assert!(self.line_metrics.len() > 0);
799
800        self.bonus_height = if self.text.is_empty() || util::trailing_nlf(&self.text).is_some() {
801            self.line_metrics.last().unwrap().height
802        } else {
803            0.0
804        };
805
806        let mut line_bounds = frame
807            .lines()
808            .iter()
809            .map(Line::get_image_bounds)
810            .zip(self.line_metrics.iter().map(|l| l.y_offset + l.baseline))
811            // these are relative to the baseline *and* upside down, so we invert y
812            .map(|(rect, y_pos)| Rect::new(rect.x0, y_pos - rect.y1, rect.x1, y_pos - rect.y0));
813
814        let first_line_bounds = line_bounds.next().unwrap_or_default();
815        self.image_bounds = line_bounds.fold(first_line_bounds, |acc, el| acc.union(el));
816        self.frame = Some(frame);
817    }
818
819    pub(crate) fn draw(&self, ctx: &mut CGContextRef) {
820        let lines = self.unwrap_frame().lines();
821        let lines_len = lines.len();
822        assert!(self.x_offsets.len() >= lines_len);
823        assert!(self.line_metrics.len() >= lines_len);
824
825        for (i, line) in lines.iter().enumerate() {
826            let x = self.x_offsets.get(i).copied().unwrap_or_default();
827            // because coretext has an inverted coordinate system we have to manually flip lines
828            let y_off = self
829                .line_metrics
830                .get(i)
831                .map(|lm| lm.y_offset + lm.baseline)
832                .unwrap_or_default();
833            let y = self.frame_size.height - y_off;
834            ctx.set_text_position(x, y);
835            line.draw(ctx)
836        }
837    }
838
839    #[inline]
840    fn unwrap_frame(&self) -> &Frame {
841        self.frame.as_ref().expect("always inited in ::new")
842    }
843
844    fn line_number_for_utf8_offset(&self, offset: usize) -> usize {
845        match self
846            .line_metrics
847            .binary_search_by_key(&offset, |lm| lm.start_offset)
848        {
849            Ok(line) => line,
850            Err(line) => line.saturating_sub(1),
851        }
852    }
853
854    fn line_range(&self, line: usize) -> Option<(usize, usize)> {
855        self.line_metrics
856            .get(line)
857            .map(|lm| (lm.start_offset, lm.end_offset))
858    }
859
860    #[allow(dead_code)]
861    fn debug_print_lines(&self) {
862        for (i, lm) in self.line_metrics.iter().enumerate() {
863            let range = lm.range();
864            println!(
865                "L{} ({}..{}): '{}'",
866                i,
867                range.start,
868                range.end,
869                &self.text[lm.range()].escape_debug()
870            );
871        }
872    }
873}
874
875struct LayoutMetrics {
876    line_metrics: Vec<LineMetric>,
877    trailing_whitespace: f64,
878    x_offsets: Vec<f64>,
879    layout_size: Size,
880}
881
882/// Returns metrics, x_offsets, and the max width including trailing whitespace.
883#[allow(clippy::while_let_on_iterator)]
884fn build_line_metrics(
885    frame: &Frame,
886    text: &str,
887    default_line_height: f64,
888    default_baseline: f64,
889) -> LayoutMetrics {
890    let line_origins = frame.get_line_origins(CFRange::init(0, 0));
891    assert_eq!(frame.lines().len(), line_origins.len());
892
893    let mut metrics = Vec::with_capacity(frame.lines().len() + 1);
894    let mut x_offsets = Vec::with_capacity(frame.lines().len() + 1);
895    let mut cumulative_height = 0.0;
896    let mut max_width = 0f64;
897    let mut max_width_with_ws = 0f64;
898
899    let mut chars = text.chars();
900    let mut cur_16 = 0;
901    let mut cur_8 = 0;
902
903    // a closure for converting our offsets
904    let mut utf16_to_utf8 = |off_16| {
905        if off_16 == 0 {
906            0
907        } else {
908            while let Some(c) = chars.next() {
909                cur_16 += c.len_utf16();
910                cur_8 += c.len_utf8();
911                if cur_16 == off_16 {
912                    return cur_8;
913                }
914            }
915            panic!("error calculating utf8 offsets");
916        }
917    };
918
919    let mut last_line_end = 0;
920    for (i, line) in frame.lines().iter().enumerate() {
921        let range = line.get_string_range();
922
923        let start_offset = last_line_end;
924        let end_offset = utf16_to_utf8((range.location + range.length) as usize);
925        last_line_end = end_offset;
926
927        let trailing_whitespace = count_trailing_ws(&text[start_offset..end_offset]);
928
929        let ws_width = line.get_trailing_whitespace_width();
930        let typo_bounds = line.get_typographic_bounds();
931        max_width_with_ws = max_width_with_ws.max(typo_bounds.width);
932        max_width = max_width.max(typo_bounds.width - ws_width);
933
934        let baseline = (typo_bounds.ascent + 0.5).floor();
935        let height =
936            compute_line_height(typo_bounds.ascent, typo_bounds.descent, typo_bounds.leading);
937        let y_offset = cumulative_height;
938        cumulative_height += height;
939
940        metrics.push(LineMetric {
941            start_offset,
942            end_offset,
943            trailing_whitespace,
944            baseline,
945            height,
946            y_offset,
947        });
948        x_offsets.push(line_origins[i].x);
949    }
950
951    // adjust our x_offsets so that we zero leading whitespace (relevant if right-aligned)
952    let min_x_offset = if x_offsets.is_empty() {
953        0.0
954    } else {
955        x_offsets
956            .iter()
957            .fold(f64::MAX, |mx, this| if *this < mx { *this } else { mx })
958    };
959    x_offsets.iter_mut().for_each(|off| *off -= min_x_offset);
960
961    // empty string is treated as a single empty line
962    if text.is_empty() {
963        metrics.push(LineMetric {
964            height: default_line_height,
965            baseline: default_baseline,
966            ..Default::default()
967        });
968    // newline at EOF is treated as an additional empty line
969    } else if util::trailing_nlf(text).is_some() {
970        let newline_eof = metrics
971            .last()
972            .map(|lm| {
973                LineMetric {
974                    start_offset: text.len(),
975                    end_offset: text.len(),
976                    // use height and baseline of preceding line; more likely
977                    // to be correct than the default.
978                    // FIXME: for this to be actually correct we would need the metrics
979                    // of the font used in the line's last run
980                    height: lm.height,
981                    baseline: lm.baseline,
982                    y_offset: lm.y_offset + lm.height,
983                    trailing_whitespace: 0,
984                }
985            })
986            .unwrap();
987        let x_offset = x_offsets.last().copied().unwrap();
988        metrics.push(newline_eof);
989        x_offsets.push(x_offset);
990    }
991
992    let layout_size = Size::new(max_width, cumulative_height);
993
994    LayoutMetrics {
995        line_metrics: metrics,
996        x_offsets,
997        layout_size,
998        trailing_whitespace: max_width_with_ws,
999    }
1000}
1001
1002// this may not be exactly right, but i'm also not sure we ever use this?
1003// see https://stackoverflow.com/questions/5511830/how-does-line-spacing-work-in-core-text-and-why-is-it-different-from-nslayoutm
1004fn compute_line_height(ascent: f64, descent: f64, leading: f64) -> f64 {
1005    let leading = leading.max(0.0);
1006    let leading = (leading + 0.5).floor();
1007    leading + (descent + 0.5).floor() + (ascent + 0.5).floor()
1008    // in the link they also calculate an ascender delta that is used to adjust line
1009    // spacing in some cases, but this feels finicky and we can choose not to do it.
1010}
1011
1012fn count_trailing_ws(s: &str) -> usize {
1013    //FIXME: this is just ascii whitespace
1014    s.as_bytes()
1015        .iter()
1016        .rev()
1017        .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
1018        .count()
1019}
1020
1021/// Generate an opentype tag. The string should be exactly 4 bytes long.
1022///
1023/// ```no_compile
1024/// const WEIGHT_AXIS = make_opentype_tag("wght");
1025/// ```
1026const fn make_opentype_tag(raw: &str) -> u32 {
1027    let b = raw.as_bytes();
1028    ((b[0] as u32) << 24) | ((b[1] as u32) << 16) | ((b[2] as u32) << 8) | (b[3] as u32)
1029}
1030
1031#[cfg(test)]
1032#[allow(clippy::float_cmp)]
1033mod tests {
1034    use super::*;
1035
1036    macro_rules! assert_close {
1037        ($val:expr, $target:expr, $tolerance:expr) => {{
1038            let min = $target - $tolerance;
1039            let max = $target + $tolerance;
1040            if $val < min || $val > max {
1041                panic!(
1042                    "value {} outside target {} with tolerance {}",
1043                    $val, $target, $tolerance
1044                );
1045            }
1046        }};
1047
1048        ($val:expr, $target:expr, $tolerance:expr,) => {{
1049            assert_close!($val, $target, $tolerance)
1050        }};
1051    }
1052
1053    #[test]
1054    fn line_offsets() {
1055        let text = "hi\ni'm\nπŸ˜€ four\nlines";
1056        let a_font = FontFamily::new_unchecked("Helvetica");
1057        let layout = CoreGraphicsText::new_with_unique_state()
1058            .new_text_layout(text)
1059            .font(a_font, 16.0)
1060            .build()
1061            .unwrap();
1062        assert_eq!(layout.line_text(0), Some("hi\n"));
1063        assert_eq!(layout.line_text(1), Some("i'm\n"));
1064        assert_eq!(layout.line_text(2), Some("πŸ˜€ four\n"));
1065        assert_eq!(layout.line_text(3), Some("lines"));
1066    }
1067
1068    #[test]
1069    fn metrics() {
1070        let text = "🀑:\na string\nwith a number \n of lines";
1071        let a_font = FontFamily::new_unchecked("Helvetica");
1072        let layout = CoreGraphicsText::new_with_unique_state()
1073            .new_text_layout(text)
1074            .font(a_font, 16.0)
1075            .build()
1076            .unwrap();
1077
1078        let line1 = layout.line_metric(0).unwrap();
1079        assert_eq!(line1.range(), 0..6);
1080        assert_eq!(line1.trailing_whitespace, 1);
1081        layout.line_metric(1);
1082
1083        let line3 = layout.line_metric(2).unwrap();
1084        assert_eq!(line3.range(), 15..30);
1085        assert_eq!(line3.trailing_whitespace, 2);
1086
1087        let line4 = layout.line_metric(3).unwrap();
1088        assert_eq!(layout.line_text(3), Some(" of lines"));
1089        assert_eq!(line4.trailing_whitespace, 0);
1090
1091        let total_height = layout.frame_size.height;
1092        assert_eq!(line4.y_offset + line4.height, total_height);
1093
1094        assert!(layout.line_metric(4).is_none());
1095    }
1096
1097    // test that at least we're landing on the correct line
1098    #[test]
1099    fn basic_hit_testing() {
1100        let text = "1\nπŸ˜€\n8\nA";
1101        let a_font = FontFamily::new_unchecked("Helvetica");
1102        let layout = CoreGraphicsText::new_with_unique_state()
1103            .new_text_layout(text)
1104            .font(a_font, 16.0)
1105            .build()
1106            .unwrap();
1107
1108        assert_eq!(layout.line_count(), 4);
1109
1110        let p1 = layout.hit_test_point(Point::ZERO);
1111        assert_eq!(p1.idx, 0);
1112        assert!(p1.is_inside);
1113        let p2 = layout.hit_test_point(Point::new(2.0, 15.9));
1114        assert_eq!(p2.idx, 0);
1115        assert!(p2.is_inside);
1116
1117        let p3 = layout.hit_test_point(Point::new(50.0, 10.0));
1118        assert_eq!(p3.idx, 1);
1119        assert!(!p3.is_inside);
1120
1121        let p4 = layout.hit_test_point(Point::new(4.0, 25.0));
1122        assert_eq!(p4.idx, 2);
1123        assert!(p4.is_inside);
1124
1125        let p5 = layout.hit_test_point(Point::new(2.0, 64.0));
1126        assert_eq!(p5.idx, 9);
1127        assert!(p5.is_inside);
1128
1129        let p6 = layout.hit_test_point(Point::new(10.0, 64.0));
1130        assert_eq!(p6.idx, 10);
1131        assert!(p6.is_inside);
1132    }
1133
1134    #[test]
1135    fn hit_test_end_of_single_line() {
1136        let text = "hello";
1137        let a_font = FontFamily::new_unchecked("Helvetica");
1138        let layout = CoreGraphicsText::new_with_unique_state()
1139            .new_text_layout(text)
1140            .font(a_font, 16.0)
1141            .build()
1142            .unwrap();
1143        let pt = layout.hit_test_point(Point::new(0.0, 5.0));
1144        assert_eq!(pt.idx, 0);
1145        assert!(pt.is_inside);
1146        let next_to_last = layout.frame_size.width - 10.0;
1147        let pt = layout.hit_test_point(Point::new(next_to_last, 0.0));
1148        assert_eq!(pt.idx, 4);
1149        assert!(pt.is_inside);
1150        let pt = layout.hit_test_point(Point::new(100.0, 5.0));
1151        assert_eq!(pt.idx, 5);
1152        assert!(!pt.is_inside);
1153    }
1154
1155    #[test]
1156    fn hit_test_empty_string() {
1157        let a_font = FontFamily::new_unchecked("Helvetica");
1158        let layout = CoreGraphicsText::new_with_unique_state()
1159            .new_text_layout("")
1160            .font(a_font, 12.0)
1161            .build()
1162            .unwrap();
1163        let pt = layout.hit_test_point(Point::new(0.0, 0.0));
1164        assert_eq!(pt.idx, 0);
1165        let pos = layout.hit_test_text_position(0);
1166        assert_eq!(pos.point.x, 0.0);
1167        assert_close!(pos.point.y, 10.0, 3.0);
1168        let line = layout.line_metric(0).unwrap();
1169        assert_close!(line.height, 12.0, 3.0);
1170    }
1171
1172    #[test]
1173    fn hit_test_text_position() {
1174        let text = "aaaaa\nbbbbb";
1175        let a_font = FontFamily::new_unchecked("Helvetica");
1176        let layout = CoreGraphicsText::new_with_unique_state()
1177            .new_text_layout(text)
1178            .font(a_font, 16.0)
1179            .build()
1180            .unwrap();
1181        let p1 = layout.hit_test_text_position(0);
1182        assert_close!(p1.point.y, 12.0, 0.5);
1183
1184        let p1 = layout.hit_test_text_position(7);
1185        assert_close!(p1.point.y, 28.0, 0.5);
1186        // just the general idea that this is the second character
1187        assert_close!(p1.point.x, 10.0, 5.0);
1188    }
1189
1190    #[test]
1191    fn hit_test_text_position_astral_plane() {
1192        let text = "πŸ‘ΎπŸ€ \nπŸ€–πŸŽƒπŸ‘Ύ";
1193        let a_font = FontFamily::new_unchecked("Helvetica");
1194        let layout = CoreGraphicsText::new_with_unique_state()
1195            .new_text_layout(text)
1196            .font(a_font, 16.0)
1197            .build()
1198            .unwrap();
1199        let p0 = layout.hit_test_text_position(4);
1200        let p1 = layout.hit_test_text_position(8);
1201        let p2 = layout.hit_test_text_position(13);
1202
1203        assert!(p1.point.x > p0.point.x);
1204        assert!(p1.point.y == p0.point.y);
1205        assert!(p2.point.y > p1.point.y);
1206    }
1207
1208    #[test]
1209    fn missing_font_is_missing() {
1210        assert!(CoreGraphicsText::new_with_unique_state()
1211            .font_family("Segoe UI")
1212            .is_none());
1213    }
1214
1215    #[test]
1216    fn line_text_empty_string() {
1217        let layout = CoreGraphicsText::new_with_unique_state()
1218            .new_text_layout("")
1219            .build()
1220            .unwrap();
1221        assert_eq!(layout.line_text(0), Some(""));
1222    }
1223
1224    /// Trailing whitespace should all be included in the text of the line,
1225    /// and should be reported in the `trailing_whitespace` field of the line metrics.
1226    #[test]
1227    fn line_test_tabs() {
1228        let line_text = "a\t\t\t\t\n";
1229        let layout = CoreGraphicsText::new_with_unique_state()
1230            .new_text_layout(line_text)
1231            .build()
1232            .unwrap();
1233        assert_eq!(layout.line_count(), 2);
1234        assert_eq!(layout.line_text(0), Some(line_text));
1235        let metrics = layout.line_metric(0).unwrap();
1236        assert_eq!(metrics.trailing_whitespace, line_text.len() - 1);
1237    }
1238}