use std::fmt;
use std::hash::{Hash, Hasher};
use std::ops::{DerefMut, Range, RangeBounds};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use associative_cache::{AssociativeCache, Capacity64, HashFourWay, RoundRobinReplacement};
use core_foundation::base::TCFType;
use core_foundation::dictionary::{CFDictionary, CFMutableDictionary};
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use core_foundation_sys::base::CFRange;
use core_graphics::base::CGFloat;
use core_graphics::context::CGContextRef;
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use core_graphics::path::CGPath;
use core_text::{
font,
font::CTFont,
font_descriptor::{self, SymbolicTraitAccessors},
string_attributes,
};
use piet::kurbo::{Affine, Point, Rect, Size};
use piet::{
util, Error, FontFamily, FontStyle, FontWeight, HitTestPoint, HitTestPosition, LineMetric,
Text, TextAlignment, TextAttribute, TextLayout, TextLayoutBuilder, TextStorage,
};
use crate::ct_helpers::{self, AttributedString, FontCollection, Frame, Framesetter, Line};
const MAX_LAYOUT_CONSTRAINT: f64 = 1e9;
#[derive(Clone)]
pub struct CoreGraphicsText {
shared: SharedTextState,
}
#[derive(Clone)]
struct SharedTextState {
inner: Arc<Mutex<TextState>>,
}
type Cache<K, V> = AssociativeCache<K, V, Capacity64, HashFourWay, RoundRobinReplacement>;
struct TextState {
collection: FontCollection,
family_cache: Cache<String, Option<FontFamily>>,
font_cache: Cache<CoreTextFontKey, CTFont>,
}
#[derive(Clone)]
pub struct CoreGraphicsTextLayout {
text: Rc<dyn TextStorage>,
attr_string: AttributedString,
framesetter: Framesetter,
pub(crate) frame: Option<Frame>,
pub(crate) frame_size: Size,
bonus_height: f64,
image_bounds: Rect,
width_constraint: f64,
default_baseline: f64,
default_line_height: f64,
line_metrics: Rc<[LineMetric]>,
x_offsets: Rc<[f64]>,
trailing_ws_width: f64,
}
pub struct CoreGraphicsTextLayoutBuilder {
width: f64,
alignment: TextAlignment,
text: Rc<dyn TextStorage>,
last_resolved_pos: usize,
last_resolved_utf16: usize,
attr_string: AttributedString,
has_set_default_attrs: bool,
default_baseline: f64,
default_line_height: f64,
attrs: Attributes,
shared: SharedTextState,
}
#[derive(Default)]
struct Attributes {
defaults: util::LayoutDefaults,
font: Option<Span<FontFamily>>,
size: Option<Span<f64>>,
weight: Option<Span<FontWeight>>,
style: Option<Span<FontStyle>>,
}
#[derive(Clone)]
struct CoreTextFontKey {
font: FontFamily,
weight: FontWeight,
italic: bool,
size: f64,
}
impl PartialEq for CoreTextFontKey {
fn eq(&self, other: &CoreTextFontKey) -> bool {
self.font == other.font
&& self.weight == other.weight
&& self.italic == other.italic
&& self.size.to_bits() == other.size.to_bits()
}
}
impl Eq for CoreTextFontKey {}
impl Hash for CoreTextFontKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.font.hash(state);
self.weight.hash(state);
self.italic.hash(state);
self.size.to_bits().hash(state);
}
}
impl CoreTextFontKey {
fn create_ct_font(&self) -> CTFont {
const WEIGHT_AXIS_TAG: i32 = make_opentype_tag("wght") as i32;
const SLANT_TANGENT: f64 = 0.25;
unsafe {
let family_key =
CFString::wrap_under_create_rule(font_descriptor::kCTFontFamilyNameAttribute);
let family_name = ct_helpers::ct_family_name(&self.font, self.size);
let weight_key = CFString::wrap_under_create_rule(font_descriptor::kCTFontWeightTrait);
let weight = convert_to_coretext(self.weight);
let traits_key =
CFString::wrap_under_create_rule(font_descriptor::kCTFontTraitsAttribute);
let mut traits = CFMutableDictionary::new();
traits.set(weight_key, weight.as_CFType());
if self.italic {
let symbolic_traits_key =
CFString::wrap_under_create_rule(font_descriptor::kCTFontSymbolicTrait);
let symbolic_traits = CFNumber::from(font_descriptor::kCTFontItalicTrait as i32);
traits.set(symbolic_traits_key, symbolic_traits.as_CFType());
}
let attributes = CFDictionary::from_CFType_pairs(&[
(family_key, family_name.as_CFType()),
(traits_key, traits.as_CFType()),
]);
let descriptor = font_descriptor::new_from_attributes(&attributes);
let font = font::new_from_descriptor(&descriptor, self.size);
let needs_synthetic_ital = self.italic && !font.symbolic_traits().is_italic();
let has_var_axes = font.get_variation_axes().is_some();
if !(needs_synthetic_ital | has_var_axes) {
return font;
}
let affine = if needs_synthetic_ital {
Affine::new([1.0, 0.0, SLANT_TANGENT, 1.0, 0., 0.])
} else {
Affine::default()
};
let variation_axes = font
.get_variation_axes()
.map(|axes| {
axes.iter()
.flat_map(|dict| {
dict.find(ct_helpers::kCTFontVariationAxisIdentifierKey)
.and_then(|v| v.downcast::<CFNumber>().and_then(|num| num.to_i32()))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let descriptor = if variation_axes.contains(&WEIGHT_AXIS_TAG) && !self.font.is_generic()
{
let weight_axis_id: CFNumber = WEIGHT_AXIS_TAG.into();
let descriptor = font_descriptor::CTFontDescriptorCreateCopyWithVariation(
descriptor.as_concrete_TypeRef(),
weight_axis_id.as_concrete_TypeRef(),
self.weight.to_raw() as _,
);
font_descriptor::CTFontDescriptor::wrap_under_create_rule(descriptor)
} else {
descriptor
};
ct_helpers::make_font(&descriptor, self.size, affine)
}
}
}
struct Span<T> {
payload: T,
range: Range<usize>,
}
impl<T> Span<T> {
fn new(payload: T, range: Range<usize>) -> Self {
Span { payload, range }
}
fn range_end(&self) -> usize {
self.range.end
}
}
impl CoreGraphicsTextLayoutBuilder {
fn add(&mut self, attr: TextAttribute, range: Range<usize>) {
if !self.has_set_default_attrs {
self.set_default_attrs();
}
if matches!(
&attr,
TextAttribute::TextColor(_) | TextAttribute::Underline(_)
) {
return self.add_immediately(attr, range);
}
debug_assert!(
range.start >= self.last_resolved_pos,
"attributes must be added with non-decreasing start positions"
);
self.resolve_up_to(range.start);
self.attrs.add(range, attr);
}
fn set_default_attrs(&mut self) {
self.has_set_default_attrs = true;
let whole_range = self.attr_string.range();
let font = self.current_font();
let height = compute_line_height(font.ascent(), font.descent(), font.leading());
self.default_line_height = height;
self.default_baseline = (font.ascent() + 0.5).floor();
self.attr_string.set_font(whole_range, &font);
self.attr_string
.set_fg_color(whole_range, self.attrs.defaults.fg_color);
self.attr_string
.set_underline(whole_range, self.attrs.defaults.underline);
}
fn add_immediately(&mut self, attr: TextAttribute, range: Range<usize>) {
let utf16_start = util::count_utf16(&self.text[..range.start]);
let utf16_len = util::count_utf16(&self.text[range]);
let range = CFRange::init(utf16_start as isize, utf16_len as isize);
match attr {
TextAttribute::TextColor(color) => {
self.attr_string.set_fg_color(range, color);
}
TextAttribute::Underline(flag) => self.attr_string.set_underline(range, flag),
_ => unreachable!(),
}
}
fn finalize(&mut self) {
if !self.has_set_default_attrs {
self.set_default_attrs();
}
self.resolve_up_to(self.text.len());
}
fn resolve_up_to(&mut self, resolve_end: usize) {
let mut next_span_end = self.last_resolved_pos;
while next_span_end < resolve_end {
next_span_end = self.next_span_end(resolve_end);
if next_span_end > self.last_resolved_pos {
let range_end_utf16 =
util::count_utf16(&self.text[self.last_resolved_pos..next_span_end]);
let range =
CFRange::init(self.last_resolved_utf16 as isize, range_end_utf16 as isize);
let font = self.current_font();
unsafe {
self.attr_string.inner.set_attribute(
range,
string_attributes::kCTFontAttributeName,
&font,
);
}
self.last_resolved_pos = next_span_end;
self.last_resolved_utf16 += range_end_utf16;
self.update_after_adding_span();
}
}
}
fn next_span_end(&self, max: usize) -> usize {
self.attrs.next_span_end(max)
}
fn current_font(&self) -> CTFont {
self.shared.get_ct_font(&CoreTextFontKey {
font: self.attrs.font().to_owned(),
weight: self.attrs.weight(),
italic: self.attrs.italic(),
size: self.attrs.size(),
})
}
fn update_after_adding_span(&mut self) {
self.attrs.clear_up_to(self.last_resolved_pos)
}
}
impl Attributes {
fn add(&mut self, range: Range<usize>, attr: TextAttribute) {
match attr {
TextAttribute::FontFamily(font) => self.font = Some(Span::new(font, range)),
TextAttribute::Weight(w) => self.weight = Some(Span::new(w, range)),
TextAttribute::FontSize(s) => self.size = Some(Span::new(s, range)),
TextAttribute::Style(s) => self.style = Some(Span::new(s, range)),
TextAttribute::Strikethrough(_) => {
}
_ => unreachable!(),
}
}
fn size(&self) -> f64 {
self.size
.as_ref()
.map(|s| s.payload)
.unwrap_or(self.defaults.font_size)
}
fn weight(&self) -> FontWeight {
self.weight
.as_ref()
.map(|w| w.payload)
.unwrap_or(self.defaults.weight)
}
fn italic(&self) -> bool {
matches!(
self.style
.as_ref()
.map(|t| t.payload)
.unwrap_or(self.defaults.style),
FontStyle::Italic
)
}
fn font(&self) -> &FontFamily {
self.font
.as_ref()
.map(|t| &t.payload)
.unwrap_or_else(|| &self.defaults.font)
}
fn next_span_end(&self, max: usize) -> usize {
self.font
.as_ref()
.map(Span::range_end)
.unwrap_or(max)
.min(self.size.as_ref().map(Span::range_end).unwrap_or(max))
.min(self.weight.as_ref().map(Span::range_end).unwrap_or(max))
.min(self.style.as_ref().map(Span::range_end).unwrap_or(max))
.min(max)
}
fn clear_up_to(&mut self, last_pos: usize) {
if self.font.as_ref().map(Span::range_end) == Some(last_pos) {
self.font = None;
}
if self.weight.as_ref().map(Span::range_end) == Some(last_pos) {
self.weight = None;
}
if self.style.as_ref().map(Span::range_end) == Some(last_pos) {
self.style = None;
}
if self.size.as_ref().map(Span::range_end) == Some(last_pos) {
self.size = None;
}
}
}
fn convert_to_coretext(weight: FontWeight) -> CFNumber {
match weight.to_raw() {
0..=199 => -0.8,
200..=299 => -0.6,
300..=399 => -0.4,
400..=499 => 0.0,
500..=599 => 0.23,
600..=699 => 0.3,
700..=799 => 0.4,
800..=899 => 0.56,
_ => 0.62,
}
.into()
}
impl CoreGraphicsText {
pub fn new_with_unique_state() -> CoreGraphicsText {
let collection = FontCollection::new_with_all_fonts();
let inner = Arc::new(Mutex::new(TextState {
collection,
family_cache: Default::default(),
font_cache: Default::default(),
}));
CoreGraphicsText {
shared: SharedTextState { inner },
}
}
}
impl fmt::Debug for CoreGraphicsText {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("CoreGraphicsText").finish()
}
}
impl Text for CoreGraphicsText {
type TextLayout = CoreGraphicsTextLayout;
type TextLayoutBuilder = CoreGraphicsTextLayoutBuilder;
fn font_family(&mut self, family_name: &str) -> Option<FontFamily> {
self.shared.get_font_family(family_name)
}
fn new_text_layout(&mut self, text: impl TextStorage) -> Self::TextLayoutBuilder {
CoreGraphicsTextLayoutBuilder::new(text, self.shared.clone())
}
fn load_font(&mut self, data: &[u8]) -> Result<FontFamily, Error> {
ct_helpers::add_font(data)
.map(FontFamily::new_unchecked)
.map_err(|_| Error::MissingFont)
}
}
impl SharedTextState {
fn get_font_family(&self, family_name: &str) -> Option<FontFamily> {
let mut inner = self.inner.lock().unwrap();
let obj = inner.deref_mut();
let family_cache = &mut obj.family_cache;
let collection = &mut obj.collection;
family_cache
.entry(family_name)
.or_insert_with(
|| family_name.to_owned(),
|| collection.font_for_family_name(family_name),
)
.clone()
}
fn get_ct_font(&self, key: &CoreTextFontKey) -> CTFont {
let mut inner = self.inner.lock().unwrap();
inner
.font_cache
.entry(key)
.or_insert_with(|| key.to_owned(), || key.create_ct_font())
.clone()
}
}
impl CoreGraphicsTextLayoutBuilder {
fn new(text: impl TextStorage, shared: SharedTextState) -> Self {
let text = Rc::new(text);
let attr_string = AttributedString::new(text.as_str());
CoreGraphicsTextLayoutBuilder {
shared,
width: MAX_LAYOUT_CONSTRAINT,
alignment: TextAlignment::default(),
attrs: Default::default(),
text,
last_resolved_pos: 0,
last_resolved_utf16: 0,
attr_string,
has_set_default_attrs: false,
default_baseline: 0.0,
default_line_height: 0.0,
}
}
}
impl fmt::Debug for CoreGraphicsTextLayoutBuilder {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("CoreGraphicsTextLayoutBuilder").finish()
}
}
impl TextLayoutBuilder for CoreGraphicsTextLayoutBuilder {
type Out = CoreGraphicsTextLayout;
fn max_width(mut self, width: f64) -> Self {
self.width = width;
self
}
fn alignment(mut self, alignment: piet::TextAlignment) -> Self {
self.alignment = alignment;
self
}
fn default_attribute(mut self, attribute: impl Into<TextAttribute>) -> Self {
debug_assert!(
!self.has_set_default_attrs,
"default attributes mut be added before range attributes"
);
let attribute = attribute.into();
self.attrs.defaults.set(attribute);
self
}
fn range_attribute(
mut self,
range: impl RangeBounds<usize>,
attribute: impl Into<TextAttribute>,
) -> Self {
let range = util::resolve_range(range, self.text.len());
let attribute = attribute.into();
self.add(attribute, range);
self
}
fn build(mut self) -> Result<Self::Out, Error> {
self.finalize();
self.attr_string.set_alignment(self.alignment);
Ok(CoreGraphicsTextLayout::new(
self.text,
self.attr_string,
self.width,
self.default_baseline,
self.default_line_height,
))
}
}
impl fmt::Debug for CoreGraphicsTextLayout {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("CoreGraphicsTextLayout").finish()
}
}
impl TextLayout for CoreGraphicsTextLayout {
fn size(&self) -> Size {
Size::new(
self.frame_size.width,
self.frame_size.height + self.bonus_height,
)
}
fn trailing_whitespace_width(&self) -> f64 {
self.trailing_ws_width
}
fn image_bounds(&self) -> Rect {
self.image_bounds
}
fn text(&self) -> &str {
&self.text
}
fn line_text(&self, line_number: usize) -> Option<&str> {
self.line_range(line_number)
.map(|(start, end)| unsafe { self.text.get_unchecked(start..end) })
}
fn line_metric(&self, line_number: usize) -> Option<LineMetric> {
self.line_metrics.get(line_number).cloned()
}
fn line_count(&self) -> usize {
self.line_metrics.len()
}
fn hit_test_point(&self, point: Point) -> HitTestPoint {
let line_num = self
.line_metrics
.iter()
.position(|lm| lm.y_offset + lm.height >= point.y)
.unwrap_or_else(|| self.line_metrics.len().saturating_sub(1));
let line = match self.unwrap_frame().get_line(line_num) {
Some(line) => line,
None => {
assert!(self.text.is_empty() || util::trailing_nlf(&self.text).is_some());
return HitTestPoint::new(self.text.len(), false);
}
};
let line_text = self.line_text(line_num).unwrap();
let metric = &self.line_metrics[line_num];
let x_offset = self.x_offsets[line_num];
let fake_y = metric.y_offset + metric.baseline;
let fake_y = -(self.frame_size.height - fake_y);
let point_in_string_space = CGPoint::new(point.x - x_offset, fake_y);
let offset_utf16 = line.get_string_index_for_position(point_in_string_space);
let mut offset = match offset_utf16 {
-1 => self.text.len(),
n if n >= 0 => {
let utf16_range = line.get_string_range();
let rel_offset = (n - utf16_range.location) as usize;
metric.start_offset
+ util::count_until_utf16(line_text, rel_offset).unwrap_or(line_text.len())
}
_ => panic!("gross violation of api contract"),
};
if offset == metric.end_offset {
offset -= util::trailing_nlf(line_text).unwrap_or(0);
};
let typo_bounds = line.get_typographic_bounds();
let is_inside_y = point.y >= 0. && point.y <= self.frame_size.height;
let is_inside_x =
point_in_string_space.x >= 0. && point_in_string_space.x <= typo_bounds.width;
let is_inside = is_inside_x && is_inside_y;
HitTestPoint::new(offset, is_inside)
}
fn hit_test_text_position(&self, idx: usize) -> HitTestPosition {
let idx = idx.min(self.text.len());
assert!(self.text.is_char_boundary(idx));
let line_num = self.line_number_for_utf8_offset(idx);
let line = match self.unwrap_frame().get_line(line_num) {
Some(line) => line,
None => {
assert!(self.text.is_empty() || util::trailing_nlf(&self.text).is_some());
let lm = &self.line_metrics[line_num];
let y_pos = lm.y_offset + lm.baseline;
return HitTestPosition::new(Point::new(0., y_pos), line_num);
}
};
let text = self.line_text(line_num).unwrap();
let metric = &self.line_metrics[line_num];
let x_offset = self.x_offsets[line_num];
let offset_remainder = idx - metric.start_offset;
let off16: usize = util::count_utf16(&text[..offset_remainder]);
let line_range = line.get_string_range();
let char_idx = line_range.location + off16 as isize;
let x_pos = line.get_offset_for_string_index(char_idx) + x_offset;
let y_pos = metric.y_offset + metric.baseline;
HitTestPosition::new(Point::new(x_pos, y_pos), line_num)
}
}
impl CoreGraphicsTextLayout {
fn new(
text: Rc<dyn TextStorage>,
attr_string: AttributedString,
width_constraint: f64,
default_baseline: f64,
default_line_height: f64,
) -> Self {
let framesetter = Framesetter::new(&attr_string);
let mut layout = CoreGraphicsTextLayout {
text,
attr_string,
framesetter,
frame: None,
frame_size: Size::ZERO,
bonus_height: 0.0,
image_bounds: Rect::ZERO,
width_constraint: f64::NAN,
default_baseline,
default_line_height,
line_metrics: Rc::new([]),
x_offsets: Rc::new([]),
trailing_ws_width: 0.0,
};
layout.update_width(width_constraint);
layout
}
#[allow(clippy::float_cmp)]
fn update_width(&mut self, new_width: impl Into<Option<f64>>) {
let width = new_width.into().unwrap_or(MAX_LAYOUT_CONSTRAINT);
let width = if width.is_normal() {
width
} else {
MAX_LAYOUT_CONSTRAINT
};
if width.ceil() == self.width_constraint.ceil() {
return;
}
let constraints = CGSize::new(width as CGFloat, MAX_LAYOUT_CONSTRAINT);
let char_range = self.attr_string.range();
let rect = CGRect::new(&CGPoint::new(0.0, 0.0), &constraints);
let path = CGPath::from_rect(rect, None);
self.width_constraint = width;
let frame = self.framesetter.create_frame(char_range, &path);
let layout_metrics = build_line_metrics(
&frame,
&self.text,
self.default_line_height,
self.default_baseline,
);
self.line_metrics = layout_metrics.line_metrics.into();
self.x_offsets = layout_metrics.x_offsets.into();
self.trailing_ws_width = layout_metrics.trailing_whitespace;
self.frame_size = layout_metrics.layout_size;
assert!(self.line_metrics.len() > 0);
self.bonus_height = if self.text.is_empty() || util::trailing_nlf(&self.text).is_some() {
self.line_metrics.last().unwrap().height
} else {
0.0
};
let mut line_bounds = frame
.lines()
.iter()
.map(Line::get_image_bounds)
.zip(self.line_metrics.iter().map(|l| l.y_offset + l.baseline))
.map(|(rect, y_pos)| Rect::new(rect.x0, y_pos - rect.y1, rect.x1, y_pos - rect.y0));
let first_line_bounds = line_bounds.next().unwrap_or_default();
self.image_bounds = line_bounds.fold(first_line_bounds, |acc, el| acc.union(el));
self.frame = Some(frame);
}
pub(crate) fn draw(&self, ctx: &mut CGContextRef) {
let lines = self.unwrap_frame().lines();
let lines_len = lines.len();
assert!(self.x_offsets.len() >= lines_len);
assert!(self.line_metrics.len() >= lines_len);
for (i, line) in lines.iter().enumerate() {
let x = self.x_offsets.get(i).copied().unwrap_or_default();
let y_off = self
.line_metrics
.get(i)
.map(|lm| lm.y_offset + lm.baseline)
.unwrap_or_default();
let y = self.frame_size.height - y_off;
ctx.set_text_position(x, y);
line.draw(ctx)
}
}
#[inline]
fn unwrap_frame(&self) -> &Frame {
self.frame.as_ref().expect("always inited in ::new")
}
fn line_number_for_utf8_offset(&self, offset: usize) -> usize {
match self
.line_metrics
.binary_search_by_key(&offset, |lm| lm.start_offset)
{
Ok(line) => line,
Err(line) => line.saturating_sub(1),
}
}
fn line_range(&self, line: usize) -> Option<(usize, usize)> {
self.line_metrics
.get(line)
.map(|lm| (lm.start_offset, lm.end_offset))
}
#[allow(dead_code)]
fn debug_print_lines(&self) {
for (i, lm) in self.line_metrics.iter().enumerate() {
let range = lm.range();
println!(
"L{} ({}..{}): '{}'",
i,
range.start,
range.end,
&self.text[lm.range()].escape_debug()
);
}
}
}
struct LayoutMetrics {
line_metrics: Vec<LineMetric>,
trailing_whitespace: f64,
x_offsets: Vec<f64>,
layout_size: Size,
}
#[allow(clippy::while_let_on_iterator)]
fn build_line_metrics(
frame: &Frame,
text: &str,
default_line_height: f64,
default_baseline: f64,
) -> LayoutMetrics {
let line_origins = frame.get_line_origins(CFRange::init(0, 0));
assert_eq!(frame.lines().len(), line_origins.len());
let mut metrics = Vec::with_capacity(frame.lines().len() + 1);
let mut x_offsets = Vec::with_capacity(frame.lines().len() + 1);
let mut cumulative_height = 0.0;
let mut max_width = 0f64;
let mut max_width_with_ws = 0f64;
let mut chars = text.chars();
let mut cur_16 = 0;
let mut cur_8 = 0;
let mut utf16_to_utf8 = |off_16| {
if off_16 == 0 {
0
} else {
while let Some(c) = chars.next() {
cur_16 += c.len_utf16();
cur_8 += c.len_utf8();
if cur_16 == off_16 {
return cur_8;
}
}
panic!("error calculating utf8 offsets");
}
};
let mut last_line_end = 0;
for (i, line) in frame.lines().iter().enumerate() {
let range = line.get_string_range();
let start_offset = last_line_end;
let end_offset = utf16_to_utf8((range.location + range.length) as usize);
last_line_end = end_offset;
let trailing_whitespace = count_trailing_ws(&text[start_offset..end_offset]);
let ws_width = line.get_trailing_whitespace_width();
let typo_bounds = line.get_typographic_bounds();
max_width_with_ws = max_width_with_ws.max(typo_bounds.width);
max_width = max_width.max(typo_bounds.width - ws_width);
let baseline = (typo_bounds.ascent + 0.5).floor();
let height =
compute_line_height(typo_bounds.ascent, typo_bounds.descent, typo_bounds.leading);
let y_offset = cumulative_height;
cumulative_height += height;
metrics.push(LineMetric {
start_offset,
end_offset,
trailing_whitespace,
baseline,
height,
y_offset,
});
x_offsets.push(line_origins[i].x);
}
let min_x_offset = if x_offsets.is_empty() {
0.0
} else {
x_offsets
.iter()
.fold(f64::MAX, |mx, this| if *this < mx { *this } else { mx })
};
x_offsets.iter_mut().for_each(|off| *off -= min_x_offset);
if text.is_empty() {
metrics.push(LineMetric {
height: default_line_height,
baseline: default_baseline,
..Default::default()
});
} else if util::trailing_nlf(text).is_some() {
let newline_eof = metrics
.last()
.map(|lm| {
LineMetric {
start_offset: text.len(),
end_offset: text.len(),
height: lm.height,
baseline: lm.baseline,
y_offset: lm.y_offset + lm.height,
trailing_whitespace: 0,
}
})
.unwrap();
let x_offset = x_offsets.last().copied().unwrap();
metrics.push(newline_eof);
x_offsets.push(x_offset);
}
let layout_size = Size::new(max_width, cumulative_height);
LayoutMetrics {
line_metrics: metrics,
x_offsets,
layout_size,
trailing_whitespace: max_width_with_ws,
}
}
fn compute_line_height(ascent: f64, descent: f64, leading: f64) -> f64 {
let leading = leading.max(0.0);
let leading = (leading + 0.5).floor();
leading + (descent + 0.5).floor() + (ascent + 0.5).floor()
}
fn count_trailing_ws(s: &str) -> usize {
s.as_bytes()
.iter()
.rev()
.take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
.count()
}
const fn make_opentype_tag(raw: &str) -> u32 {
let b = raw.as_bytes();
((b[0] as u32) << 24) | ((b[1] as u32) << 16) | ((b[2] as u32) << 8) | (b[3] as u32)
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
macro_rules! assert_close {
($val:expr, $target:expr, $tolerance:expr) => {{
let min = $target - $tolerance;
let max = $target + $tolerance;
if $val < min || $val > max {
panic!(
"value {} outside target {} with tolerance {}",
$val, $target, $tolerance
);
}
}};
($val:expr, $target:expr, $tolerance:expr,) => {{
assert_close!($val, $target, $tolerance)
}};
}
#[test]
fn line_offsets() {
let text = "hi\ni'm\nπ four\nlines";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
assert_eq!(layout.line_text(0), Some("hi\n"));
assert_eq!(layout.line_text(1), Some("i'm\n"));
assert_eq!(layout.line_text(2), Some("π four\n"));
assert_eq!(layout.line_text(3), Some("lines"));
}
#[test]
fn metrics() {
let text = "π€‘:\na string\nwith a number \n of lines";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
let line1 = layout.line_metric(0).unwrap();
assert_eq!(line1.range(), 0..6);
assert_eq!(line1.trailing_whitespace, 1);
layout.line_metric(1);
let line3 = layout.line_metric(2).unwrap();
assert_eq!(line3.range(), 15..30);
assert_eq!(line3.trailing_whitespace, 2);
let line4 = layout.line_metric(3).unwrap();
assert_eq!(layout.line_text(3), Some(" of lines"));
assert_eq!(line4.trailing_whitespace, 0);
let total_height = layout.frame_size.height;
assert_eq!(line4.y_offset + line4.height, total_height);
assert!(layout.line_metric(4).is_none());
}
#[test]
fn basic_hit_testing() {
let text = "1\nπ\n8\nA";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
assert_eq!(layout.line_count(), 4);
let p1 = layout.hit_test_point(Point::ZERO);
assert_eq!(p1.idx, 0);
assert!(p1.is_inside);
let p2 = layout.hit_test_point(Point::new(2.0, 15.9));
assert_eq!(p2.idx, 0);
assert!(p2.is_inside);
let p3 = layout.hit_test_point(Point::new(50.0, 10.0));
assert_eq!(p3.idx, 1);
assert!(!p3.is_inside);
let p4 = layout.hit_test_point(Point::new(4.0, 25.0));
assert_eq!(p4.idx, 2);
assert!(p4.is_inside);
let p5 = layout.hit_test_point(Point::new(2.0, 64.0));
assert_eq!(p5.idx, 9);
assert!(p5.is_inside);
let p6 = layout.hit_test_point(Point::new(10.0, 64.0));
assert_eq!(p6.idx, 10);
assert!(p6.is_inside);
}
#[test]
fn hit_test_end_of_single_line() {
let text = "hello";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
let pt = layout.hit_test_point(Point::new(0.0, 5.0));
assert_eq!(pt.idx, 0);
assert!(pt.is_inside);
let next_to_last = layout.frame_size.width - 10.0;
let pt = layout.hit_test_point(Point::new(next_to_last, 0.0));
assert_eq!(pt.idx, 4);
assert!(pt.is_inside);
let pt = layout.hit_test_point(Point::new(100.0, 5.0));
assert_eq!(pt.idx, 5);
assert!(!pt.is_inside);
}
#[test]
fn hit_test_empty_string() {
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout("")
.font(a_font, 12.0)
.build()
.unwrap();
let pt = layout.hit_test_point(Point::new(0.0, 0.0));
assert_eq!(pt.idx, 0);
let pos = layout.hit_test_text_position(0);
assert_eq!(pos.point.x, 0.0);
assert_close!(pos.point.y, 10.0, 3.0);
let line = layout.line_metric(0).unwrap();
assert_close!(line.height, 12.0, 3.0);
}
#[test]
fn hit_test_text_position() {
let text = "aaaaa\nbbbbb";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
let p1 = layout.hit_test_text_position(0);
assert_close!(p1.point.y, 12.0, 0.5);
let p1 = layout.hit_test_text_position(7);
assert_close!(p1.point.y, 28.0, 0.5);
assert_close!(p1.point.x, 10.0, 5.0);
}
#[test]
fn hit_test_text_position_astral_plane() {
let text = "πΎπ€ \nπ€ππΎ";
let a_font = FontFamily::new_unchecked("Helvetica");
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(text)
.font(a_font, 16.0)
.build()
.unwrap();
let p0 = layout.hit_test_text_position(4);
let p1 = layout.hit_test_text_position(8);
let p2 = layout.hit_test_text_position(13);
assert!(p1.point.x > p0.point.x);
assert!(p1.point.y == p0.point.y);
assert!(p2.point.y > p1.point.y);
}
#[test]
fn missing_font_is_missing() {
assert!(CoreGraphicsText::new_with_unique_state()
.font_family("Segoe UI")
.is_none());
}
#[test]
fn line_text_empty_string() {
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout("")
.build()
.unwrap();
assert_eq!(layout.line_text(0), Some(""));
}
#[test]
fn line_test_tabs() {
let line_text = "a\t\t\t\t\n";
let layout = CoreGraphicsText::new_with_unique_state()
.new_text_layout(line_text)
.build()
.unwrap();
assert_eq!(layout.line_count(), 2);
assert_eq!(layout.line_text(0), Some(line_text));
let metrics = layout.line_metric(0).unwrap();
assert_eq!(metrics.trailing_whitespace, line_text.len() - 1);
}
}