piet_web/
text.rs

1// Copyright 2019 the Piet Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Text functionality for Piet web backend
5
6mod grapheme;
7mod lines;
8
9use std::borrow::Cow;
10use std::fmt;
11use std::ops::RangeBounds;
12use std::rc::Rc;
13
14use web_sys::CanvasRenderingContext2d;
15
16use piet::kurbo::{Point, Rect, Size};
17
18use piet::{
19    Color, Error, FontFamily, HitTestPoint, HitTestPosition, LineMetric, Text, TextAttribute,
20    TextLayout, TextLayoutBuilder, TextStorage, util,
21};
22use unicode_segmentation::UnicodeSegmentation;
23
24use self::grapheme::{get_grapheme_boundaries, point_x_in_grapheme};
25use crate::WebText;
26
27#[derive(Clone)]
28pub struct WebFont {
29    family: FontFamily,
30    weight: u32,
31    style: FontStyle,
32    size: f64,
33}
34
35#[derive(Clone)]
36pub struct WebTextLayout {
37    ctx: CanvasRenderingContext2d,
38    pub(crate) font: WebFont,
39    pub(crate) text: Rc<dyn TextStorage>,
40
41    // Calculated on build
42    pub(crate) line_metrics: Vec<LineMetric>,
43    size: Size,
44    trailing_ws_width: f64,
45    color: Color,
46}
47
48pub struct WebTextLayoutBuilder {
49    ctx: CanvasRenderingContext2d,
50    text: Rc<dyn TextStorage>,
51    width: f64,
52    defaults: util::LayoutDefaults,
53}
54
55/// <https://developer.mozilla.org/en-US/docs/Web/CSS/font-style>
56#[derive(Clone)]
57enum FontStyle {
58    Normal,
59    Italic,
60    #[allow(dead_code)] // Not used by piet, but here for completeness
61    Oblique(Option<f64>),
62}
63
64impl Text for WebText {
65    type TextLayout = WebTextLayout;
66    type TextLayoutBuilder = WebTextLayoutBuilder;
67
68    fn font_family(&mut self, family_name: &str) -> Option<FontFamily> {
69        Some(FontFamily::new_unchecked(family_name))
70    }
71
72    fn load_font(&mut self, _data: &[u8]) -> Result<FontFamily, Error> {
73        Err(Error::Unimplemented)
74    }
75
76    fn new_text_layout(&mut self, text: impl TextStorage) -> Self::TextLayoutBuilder {
77        WebTextLayoutBuilder {
78            // TODO: it's very likely possible to do this without cloning ctx, but
79            // I couldn't figure out the lifetime errors from a `&'a` reference.
80            ctx: self.ctx.clone(),
81            text: Rc::new(text),
82            width: f64::INFINITY,
83            defaults: Default::default(),
84        }
85    }
86}
87
88impl fmt::Debug for WebText {
89    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
90        f.debug_struct("WebText").finish()
91    }
92}
93
94impl WebFont {
95    fn new(family: FontFamily) -> Self {
96        WebFont {
97            family,
98            style: FontStyle::Normal,
99            size: piet::util::DEFAULT_FONT_SIZE,
100            weight: 400,
101        }
102    }
103
104    fn with_style(mut self, style: piet::FontStyle) -> Self {
105        let style = if style == piet::FontStyle::Italic {
106            FontStyle::Italic
107        } else {
108            FontStyle::Normal
109        };
110
111        self.style = style;
112        self
113    }
114
115    fn with_weight(mut self, weight: piet::FontWeight) -> Self {
116        self.weight = weight.to_raw() as u32;
117        self
118    }
119
120    fn with_size(mut self, size: f64) -> Self {
121        self.size = size;
122        self
123    }
124
125    pub(crate) fn get_font_string(&self) -> String {
126        let style_str = match self.style {
127            FontStyle::Normal => Cow::from("normal"),
128            FontStyle::Italic => Cow::from("italic"),
129            FontStyle::Oblique(None) => Cow::from("italic"),
130            FontStyle::Oblique(Some(angle)) => Cow::from(format!("oblique {angle}deg")),
131        };
132        format!(
133            "{} {} {}px \"{}\"",
134            style_str,
135            self.weight,
136            self.size,
137            self.family.name()
138        )
139    }
140}
141
142impl TextLayoutBuilder for WebTextLayoutBuilder {
143    type Out = WebTextLayout;
144
145    fn max_width(mut self, width: f64) -> Self {
146        self.width = width;
147        self
148    }
149
150    fn alignment(self, _alignment: piet::TextAlignment) -> Self {
151        web_sys::console::log_1(&"TextLayout alignment unsupported on web".into());
152        self
153    }
154
155    fn default_attribute(mut self, attribute: impl Into<TextAttribute>) -> Self {
156        self.defaults.set(attribute);
157        self
158    }
159
160    fn range_attribute(
161        self,
162        _range: impl RangeBounds<usize>,
163        _attribute: impl Into<TextAttribute>,
164    ) -> Self {
165        web_sys::console::log_1(&"Text attributes not yet implemented for web".into());
166        self
167    }
168
169    fn build(self) -> Result<Self::Out, Error> {
170        let font = WebFont::new(self.defaults.font)
171            .with_size(self.defaults.font_size)
172            .with_weight(self.defaults.weight)
173            .with_style(self.defaults.style);
174
175        let mut layout = WebTextLayout {
176            ctx: self.ctx,
177            font,
178            text: self.text,
179            line_metrics: Vec::new(),
180            size: Size::ZERO,
181            trailing_ws_width: 0.0,
182            color: self.defaults.fg_color,
183        };
184
185        layout.update_width(self.width);
186        Ok(layout)
187    }
188}
189
190impl fmt::Debug for WebTextLayoutBuilder {
191    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
192        f.debug_struct("WebTextLayoutBuilder").finish()
193    }
194}
195
196impl TextLayout for WebTextLayout {
197    fn size(&self) -> Size {
198        self.size
199    }
200
201    fn trailing_whitespace_width(&self) -> f64 {
202        self.trailing_ws_width
203    }
204
205    fn image_bounds(&self) -> Rect {
206        //FIXME: figure out actual image bounds on web?
207        self.size.to_rect()
208    }
209
210    fn text(&self) -> &str {
211        &self.text
212    }
213
214    fn line_text(&self, line_number: usize) -> Option<&str> {
215        self.line_metrics
216            .get(line_number)
217            .map(|lm| &self.text[lm.start_offset..lm.end_offset])
218    }
219
220    fn line_metric(&self, line_number: usize) -> Option<LineMetric> {
221        self.line_metrics.get(line_number).cloned()
222    }
223
224    fn line_count(&self) -> usize {
225        self.line_metrics.len()
226    }
227
228    fn hit_test_point(&self, point: Point) -> HitTestPoint {
229        self.ctx.set_font(&self.font.get_font_string());
230        // internal logic is using grapheme clusters, but return the text position associated
231        // with the border of the grapheme cluster.
232
233        // null case
234        if self.text.is_empty() {
235            return HitTestPoint::default();
236        }
237
238        // this assumes that all heights/baselines are the same.
239        // Uses line bounding box to do hit testpoint, but with coordinates starting at 0.0 at
240        // first baseline
241        let first_baseline = self.line_metrics.first().map(|l| l.baseline).unwrap_or(0.0);
242
243        // check out of bounds above top
244        // out of bounds on bottom during iteration
245        let mut is_y_inside = true;
246        if point.y < -first_baseline {
247            is_y_inside = false
248        };
249
250        let mut lm = self
251            .line_metrics
252            .iter()
253            .skip_while(|l| l.y_offset + l.height < point.y);
254        let lm = lm
255            .next()
256            .or_else(|| {
257                // This means it went over the last line, so return the last line.
258                is_y_inside = false;
259                self.line_metrics.last()
260            })
261            .cloned()
262            .unwrap_or_else(|| {
263                is_y_inside = false;
264                Default::default()
265            });
266
267        // Then for the line, do hit test point
268        // Trailing whitespace is remove for the line
269        let line = &self.text[lm.start_offset..lm.end_offset];
270
271        let mut htp = hit_test_line_point(&self.ctx, line, point);
272        htp.idx += lm.start_offset;
273
274        if !is_y_inside {
275            htp.is_inside = false;
276        }
277
278        htp
279    }
280
281    fn hit_test_text_position(&self, idx: usize) -> HitTestPosition {
282        self.ctx.set_font(&self.font.get_font_string());
283        let idx = idx.min(self.text.len());
284        assert!(self.text.is_char_boundary(idx));
285        // first need to find line it's on, and get line start offset
286        let line_num = util::line_number_for_position(&self.line_metrics, idx);
287        let lm = self.line_metrics.get(line_num).cloned().unwrap();
288
289        let y_pos = lm.y_offset + lm.baseline;
290        // Then for the line, do text position
291        // Trailing whitespace is removed for the line
292        let line = &self.text[lm.range()];
293        let line_position = idx - lm.start_offset;
294
295        let x_pos = hit_test_line_position(&self.ctx, line, line_position);
296        HitTestPosition::new(Point::new(x_pos, y_pos), line_num)
297    }
298}
299
300impl fmt::Debug for WebTextLayout {
301    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
302        f.debug_struct("WebTextLayout").finish()
303    }
304}
305
306impl WebTextLayout {
307    pub(crate) fn size(&self) -> Size {
308        self.size
309    }
310
311    pub(crate) fn color(&self) -> Color {
312        self.color
313    }
314
315    fn update_width(&mut self, new_width: impl Into<Option<f64>>) {
316        // various functions like `text_width` are stateful, and require
317        // the context to be configured correctly.
318        self.ctx.set_font(&self.font.get_font_string());
319        let new_width = new_width.into().unwrap_or(f64::INFINITY);
320        let mut line_metrics =
321            lines::calculate_line_metrics(&self.text, &self.ctx, new_width, self.font.size);
322
323        if self.text.is_empty() {
324            line_metrics.push(LineMetric {
325                baseline: self.font.size * 0.2,
326                height: self.font.size * 1.2,
327                ..Default::default()
328            })
329        } else if util::trailing_nlf(&self.text).is_some() {
330            assert!(!line_metrics.is_empty());
331            let newline_eof = line_metrics
332                .last()
333                .map(|lm| LineMetric {
334                    start_offset: self.text.len(),
335                    end_offset: self.text.len(),
336                    height: lm.height,
337                    baseline: lm.baseline,
338                    y_offset: lm.y_offset + lm.height,
339                    trailing_whitespace: 0,
340                })
341                .unwrap();
342            line_metrics.push(newline_eof);
343        }
344
345        let (width, ws_width) = line_metrics
346            .iter()
347            .map(|lm| {
348                let full_width = text_width(&self.text[lm.range()], &self.ctx);
349                let non_ws_width = if lm.trailing_whitespace > 0 {
350                    let non_ws_range = lm.start_offset..lm.end_offset - lm.trailing_whitespace;
351                    text_width(&self.text[non_ws_range], &self.ctx)
352                } else {
353                    full_width
354                };
355                (non_ws_width, full_width)
356            })
357            .fold((0.0, 0.0), |a: (f64, f64), b| (a.0.max(b.0), a.1.max(b.1)));
358
359        let height = line_metrics
360            .last()
361            .map(|l| l.y_offset + l.height)
362            .unwrap_or_default();
363        self.line_metrics = line_metrics;
364        self.trailing_ws_width = ws_width;
365        self.size = Size::new(width, height);
366    }
367}
368
369// NOTE this is the same as the old, non-line-aware version of hit_test_point
370// Future: instead of passing ctx, should there be some other line-level text layout?
371fn hit_test_line_point(ctx: &CanvasRenderingContext2d, text: &str, point: Point) -> HitTestPoint {
372    // null case
373    if text.is_empty() {
374        return HitTestPoint::default();
375    }
376
377    // get bounds
378    // TODO handle if string is not null yet count is 0?
379    let end = UnicodeSegmentation::graphemes(text, true).count() - 1;
380    let end_bounds = match get_grapheme_boundaries(ctx, text, end) {
381        Some(bounds) => bounds,
382        None => return HitTestPoint::default(),
383    };
384
385    let start = 0;
386    let start_bounds = match get_grapheme_boundaries(ctx, text, start) {
387        Some(bounds) => bounds,
388        None => return HitTestPoint::default(),
389    };
390
391    // first test beyond ends
392    if point.x > end_bounds.trailing {
393        return HitTestPoint::new(text.len(), false);
394    }
395
396    if point.x <= start_bounds.leading {
397        return HitTestPoint::default();
398    }
399
400    // then test the beginning and end (common cases)
401    if let Some(hit) = point_x_in_grapheme(point.x, &start_bounds) {
402        return hit;
403    }
404    if let Some(hit) = point_x_in_grapheme(point.x, &end_bounds) {
405        return hit;
406    }
407
408    // Now that we know it's not beginning or end, begin binary search.
409    // Iterative style
410    let mut left = start;
411    let mut right = end;
412    loop {
413        // pick halfway point
414        let middle = left + ((right - left) / 2);
415
416        let grapheme_bounds = match get_grapheme_boundaries(ctx, text, middle) {
417            Some(bounds) => bounds,
418            None => return HitTestPoint::default(),
419        };
420
421        if let Some(hit) = point_x_in_grapheme(point.x, &grapheme_bounds) {
422            return hit;
423        }
424
425        // since it's not a hit, check if closer to start or finish
426        // and move the appropriate search boundary
427        if point.x < grapheme_bounds.leading {
428            right = middle;
429        } else if point.x > grapheme_bounds.trailing {
430            left = middle + 1;
431        } else {
432            unreachable!("hit_test_point conditional is exhaustive");
433        }
434    }
435}
436
437// NOTE this is the same as the old, non-line-aware version of hit_test_text_position.
438// Future: instead of passing ctx, should there be some other line-level text layout?
439/// Returns the x offset of the given text position in this text.
440fn hit_test_line_position(ctx: &CanvasRenderingContext2d, text: &str, idx: usize) -> f64 {
441    // Using substrings with unicode grapheme awareness
442
443    let text_len = text.len();
444
445    if idx == 0 {
446        return 0.0;
447    }
448
449    if idx >= text_len {
450        return text_width(text, ctx);
451    }
452
453    // Already checked that text_position > 0 and text_position < count.
454    // If text position is not at a grapheme boundary, use the text position of current
455    // grapheme cluster. But return the original text position
456    // Use the indices (byte offset, which for our purposes = utf8 code units).
457    let grapheme_indices = UnicodeSegmentation::grapheme_indices(text, true)
458        .take_while(|(byte_idx, _s)| idx >= *byte_idx);
459
460    let text_end = grapheme_indices
461        .last()
462        .map(|(idx, _)| idx)
463        .unwrap_or(text_len);
464    text_width(&text[..text_end], ctx)
465}
466
467pub(crate) fn text_width(text: &str, ctx: &CanvasRenderingContext2d) -> f64 {
468    ctx.measure_text(text)
469        .map(|m| m.width())
470        .expect("Text measurement failed")
471}
472
473// NOTE these tests are currently only working on chrome.
474// Since it's so finicky, not sure it's worth making it work on both chrome and firefox until we
475// address the underlying brittlness
476#[cfg(test)]
477pub(crate) mod test {
478    use piet::kurbo::Point;
479    use piet::{Text, TextLayout, TextLayoutBuilder};
480    use wasm_bindgen_test::*;
481    use web_sys::{HtmlCanvasElement, console, window};
482
483    use crate::*;
484
485    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
486
487    fn setup_ctx() -> (Window, CanvasRenderingContext2d) {
488        let window = window().unwrap();
489        let document = window.document().unwrap();
490
491        let canvas = document
492            .create_element("canvas")
493            .unwrap()
494            .dyn_into::<HtmlCanvasElement>()
495            .unwrap();
496        let context = canvas
497            .get_context("2d")
498            .unwrap()
499            .unwrap()
500            .dyn_into::<web_sys::CanvasRenderingContext2d>()
501            .unwrap();
502
503        let dpr = window.device_pixel_ratio();
504        canvas.set_width((canvas.offset_width() as f64 * dpr) as u32);
505        canvas.set_height((canvas.offset_height() as f64 * dpr) as u32);
506        let _ = context.scale(dpr, dpr);
507
508        (window, context)
509    }
510
511    // - x: calculated value
512    // - target: f64
513    // - tolerance: in f64
514    fn assert_close_to(x: f64, target: f64, tolerance: f64) {
515        let min = target - tolerance;
516        let max = target + tolerance;
517        println!("x: {x}, target: {target}");
518        assert!(x <= max && x >= min);
519    }
520
521    #[wasm_bindgen_test]
522    pub fn test_hit_test_text_position_basic() {
523        let (_window, context) = setup_ctx();
524        let mut text_layout = WebText::new(context);
525
526        let input = "piet text!";
527        let font = text_layout.font_family("sans-serif").unwrap();
528
529        let layout = text_layout
530            .new_text_layout(&input[0..4])
531            .font(font.clone(), 12.0)
532            .build()
533            .unwrap();
534        let piet_width = layout.size().width;
535
536        let layout = text_layout
537            .new_text_layout(&input[0..3])
538            .font(font.clone(), 12.0)
539            .build()
540            .unwrap();
541        let pie_width = layout.size().width;
542
543        let layout = text_layout
544            .new_text_layout(&input[0..2])
545            .font(font.clone(), 12.0)
546            .build()
547            .unwrap();
548        let pi_width = layout.size().width;
549
550        let layout = text_layout
551            .new_text_layout(&input[0..1])
552            .font(font.clone(), 12.0)
553            .build()
554            .unwrap();
555        let p_width = layout.size().width;
556
557        let layout = text_layout
558            .new_text_layout("")
559            .font(font.clone(), 12.0)
560            .build()
561            .unwrap();
562        let null_width = layout.size().width;
563
564        let full_layout = text_layout
565            .new_text_layout(input)
566            .font(font, 12.0)
567            .build()
568            .unwrap();
569        let full_width = full_layout.size().width;
570
571        assert_close_to(
572            full_layout.hit_test_text_position(4).point.x,
573            piet_width,
574            3.0,
575        );
576        assert_close_to(
577            full_layout.hit_test_text_position(3).point.x,
578            pie_width,
579            3.0,
580        );
581        assert_close_to(full_layout.hit_test_text_position(2).point.x, pi_width, 3.0);
582        assert_close_to(full_layout.hit_test_text_position(1).point.x, p_width, 3.0);
583        assert_close_to(
584            full_layout.hit_test_text_position(0).point.x,
585            null_width,
586            3.0,
587        );
588        assert_close_to(
589            full_layout.hit_test_text_position(10).point.x,
590            full_width,
591            3.0,
592        );
593        assert_close_to(
594            full_layout.hit_test_text_position(11).point.x,
595            full_width,
596            3.0,
597        );
598    }
599
600    #[wasm_bindgen_test]
601    pub fn test_hit_test_text_position_complex_0() {
602        let (_window, context) = setup_ctx();
603        let mut text_layout = WebText::new(context);
604
605        let input = "é";
606        assert_eq!(input.len(), 2);
607
608        let font = text_layout.font_family("sans-serif").unwrap();
609        let layout = text_layout
610            .new_text_layout(input)
611            .font(font, 12.0)
612            .build()
613            .unwrap();
614
615        assert_close_to(layout.hit_test_text_position(0).point.x, 0.0, 3.0);
616        assert_close_to(
617            layout.hit_test_text_position(2).point.x,
618            layout.size().width,
619            3.0,
620        );
621
622        // note code unit not at grapheme boundary
623        // This one panics in d2d because this is not a code unit boundary.
624        // But it works here! Harder to deal with this right now, since unicode-segmentation
625        // doesn't give code point offsets.
626        assert_close_to(layout.hit_test_text_position(1).point.x, 0.0, 3.0);
627
628        // unicode segmentation is wrong on this one for now.
629        //let input = "🤦\u{1f3fc}\u{200d}\u{2642}\u{fe0f}";
630
631        //let mut text_layout = D2DText::new();
632        //let font = text_layout.new_font_by_name("sans-serif", 12.0).build().unwrap();
633        //let layout = text_layout.new_text_layout(&font, input, f64::INFINITY).build().unwrap();
634
635        //assert_eq!(input.graphemes(true).count(), 1);
636        //assert_eq!(layout.hit_test_text_position(0, true).map(|p| p.point_x as f64), Some(layout.size().width));
637        //assert_eq!(input.len(), 17);
638
639        let input = "\u{0023}\u{FE0F}\u{20E3}"; // #️⃣
640        assert_eq!(input.len(), 7);
641        assert_eq!(input.chars().count(), 3);
642
643        let font = text_layout.font_family("sans-serif").unwrap();
644        let layout = text_layout
645            .new_text_layout(input)
646            .font(font, 12.0)
647            .build()
648            .unwrap();
649
650        assert_close_to(layout.hit_test_text_position(0).point.x, 0.0, 3.0);
651        assert_close_to(
652            layout.hit_test_text_position(7).point.x,
653            layout.size().width,
654            3.0,
655        );
656
657        // note code unit not at grapheme boundary
658        assert_close_to(layout.hit_test_text_position(1).point.x, 0.0, 3.0);
659    }
660
661    #[wasm_bindgen_test]
662    pub fn test_hit_test_text_position_complex_1() {
663        let (_window, context) = setup_ctx();
664        let mut text_layout = WebText::new(context);
665
666        // Notes on this input:
667        // 6 code points
668        // 7 utf-16 code units (1/1/1/1/1/2)
669        // 14 utf-8 code units (2/1/3/3/1/4)
670        // 4 graphemes
671        let input = "é\u{0023}\u{FE0F}\u{20E3}1\u{1D407}"; // #️⃣,, 𝐇
672        assert_eq!(input.len(), 14);
673
674        let font = text_layout.font_family("sans-serif").unwrap();
675        let layout = text_layout
676            .new_text_layout(input)
677            .font(font.clone(), 12.0)
678            .build()
679            .unwrap();
680
681        let test_layout_0 = text_layout
682            .new_text_layout(&input[0..2])
683            .font(font.clone(), 12.0)
684            .build()
685            .unwrap();
686        let test_layout_1 = text_layout
687            .new_text_layout(&input[0..9])
688            .font(font.clone(), 12.0)
689            .build()
690            .unwrap();
691        let test_layout_2 = text_layout
692            .new_text_layout(&input[0..10])
693            .font(font, 12.0)
694            .build()
695            .unwrap();
696
697        // Note: text position is in terms of utf8 code units
698        assert_close_to(layout.hit_test_text_position(0).point.x, 0.0, 3.0);
699        assert_close_to(
700            layout.hit_test_text_position(2).point.x,
701            test_layout_0.size().width,
702            3.0,
703        );
704        assert_close_to(
705            layout.hit_test_text_position(9).point.x,
706            test_layout_1.size().width,
707            3.0,
708        );
709        assert_close_to(
710            layout.hit_test_text_position(10).point.x,
711            test_layout_2.size().width,
712            3.0,
713        );
714        assert_close_to(
715            layout.hit_test_text_position(14).point.x,
716            layout.size().width,
717            3.0,
718        );
719
720        // Code point boundaries, but not grapheme boundaries.
721        // Width should stay at the last complete grapheme boundary.
722        assert_close_to(layout.hit_test_text_position(1).point.x, 0.0, 3.0);
723        assert_close_to(
724            layout.hit_test_text_position(3).point.x,
725            test_layout_0.size().width,
726            3.0,
727        );
728        assert_close_to(
729            layout.hit_test_text_position(6).point.x,
730            test_layout_0.size().width,
731            3.0,
732        );
733    }
734
735    // NOTE brittle test
736    #[wasm_bindgen_test]
737    pub fn test_hit_test_point_basic_0() {
738        let (_window, context) = setup_ctx();
739        let mut text_layout = WebText::new(context);
740
741        let font = text_layout.font_family("sans-serif").unwrap();
742        let layout = text_layout
743            .new_text_layout("piet text!")
744            .font(font, 16.0)
745            .build()
746            .unwrap();
747        console::log_1(&format!("text pos 4: {:?}", layout.hit_test_text_position(4)).into()); // 23.99...
748        console::log_1(&format!("text pos 5: {:?}", layout.hit_test_text_position(5)).into()); // 27.99...
749
750        // test hit test point
751        // all inside
752        let pt = layout.hit_test_point(Point::new(22.5, 0.0));
753        assert_eq!(pt.idx, 4);
754        let pt = layout.hit_test_point(Point::new(23.0, 0.0));
755        assert_eq!(pt.idx, 4);
756        let pt = layout.hit_test_point(Point::new(25.0, 0.0));
757        assert_eq!(pt.idx, 4);
758        let pt = layout.hit_test_point(Point::new(26.0, 0.0));
759        assert_eq!(pt.idx, 5);
760        let pt = layout.hit_test_point(Point::new(27.0, 0.0));
761        assert_eq!(pt.idx, 5);
762        let pt = layout.hit_test_point(Point::new(28.0, 0.0));
763        assert_eq!(pt.idx, 5);
764
765        // outside
766        console::log_1(&format!("layout_width: {:?}", layout.size().width).into()); // 57.31...
767
768        let pt = layout.hit_test_point(Point::new(55.0, 0.0));
769        assert_eq!(pt.idx, 10); // last text position
770        assert!(pt.is_inside);
771
772        let pt = layout.hit_test_point(Point::new(58.0, 0.0));
773        assert_eq!(pt.idx, 10); // last text position
774        assert!(!pt.is_inside);
775
776        let pt = layout.hit_test_point(Point::new(-1.0, 0.0));
777        assert_eq!(pt.idx, 0); // first text position
778        assert!(!pt.is_inside);
779    }
780
781    // NOTE brittle test
782    #[wasm_bindgen_test]
783    pub fn test_hit_test_point_basic_1() {
784        let (_window, context) = setup_ctx();
785        let mut text_layout = WebText::new(context);
786
787        // base condition, one grapheme
788        let font = text_layout.font_family("sans-serif").unwrap();
789        let layout = text_layout
790            .new_text_layout("t")
791            .font(font.clone(), 16.0)
792            .build()
793            .unwrap();
794        println!("text pos 1: {:?}", layout.hit_test_text_position(1)); // 5.0
795
796        // two graphemes (to check that middle moves)
797        let pt = layout.hit_test_point(Point::new(1.0, 0.0));
798        assert_eq!(pt.idx, 0);
799
800        let layout = text_layout
801            .new_text_layout("te")
802            .font(font, 16.0)
803            .build()
804            .unwrap();
805        println!("text pos 1: {:?}", layout.hit_test_text_position(1)); // 5.0
806        println!("text pos 2: {:?}", layout.hit_test_text_position(2)); // 12.0
807
808        let pt = layout.hit_test_point(Point::new(1.0, 0.0));
809        assert_eq!(pt.idx, 0);
810        let pt = layout.hit_test_point(Point::new(4.0, 0.0));
811        assert_eq!(pt.idx, 1);
812        let pt = layout.hit_test_point(Point::new(6.0, 0.0));
813        assert_eq!(pt.idx, 1);
814        let pt = layout.hit_test_point(Point::new(11.0, 0.0));
815        assert_eq!(pt.idx, 2);
816    }
817
818    // NOTE brittle test
819    #[wasm_bindgen_test]
820    pub fn test_hit_test_point_complex_0() {
821        let (_window, context) = setup_ctx();
822        let mut text_layout = WebText::new(context);
823
824        // Notes on this input:
825        // 6 code points
826        // 7 utf-16 code units (1/1/1/1/1/2)
827        // 14 utf-8 code units (2/1/3/3/1/4)
828        // 4 graphemes
829        let input = "é\u{0023}\u{FE0F}\u{20E3}1\u{1D407}"; // #️⃣,, 𝐇
830
831        let font = text_layout
832            .font_family("sans-serif") // font size hacked to fit test
833            .unwrap();
834        let layout = text_layout
835            .new_text_layout(input)
836            .font(font, 13.0)
837            .build()
838            .unwrap();
839        console::log_1(&format!("text pos 2: {:?}", layout.hit_test_text_position(2)).into()); // 5.77...
840        console::log_1(&format!("text pos 9: {:?}", layout.hit_test_text_position(9)).into()); // 21.77...
841        console::log_1(&format!("text pos 10: {:?}", layout.hit_test_text_position(10)).into()); // 28.27...
842        console::log_1(&format!("text pos 14: {:?}", layout.hit_test_text_position(14)).into()); // 38.27..., line width
843
844        let pt = layout.hit_test_point(Point::new(2.0, 0.0));
845        assert_eq!(pt.idx, 0);
846        let pt = layout.hit_test_point(Point::new(4.0, 0.0));
847        assert_eq!(pt.idx, 2);
848        let pt = layout.hit_test_point(Point::new(7.0, 0.0));
849        assert_eq!(pt.idx, 2);
850        let pt = layout.hit_test_point(Point::new(10.0, 0.0));
851        assert_eq!(pt.idx, 2);
852        let pt = layout.hit_test_point(Point::new(14.0, 0.0));
853        assert_eq!(pt.idx, 9);
854        let pt = layout.hit_test_point(Point::new(18.0, 0.0));
855        assert_eq!(pt.idx, 9);
856        let pt = layout.hit_test_point(Point::new(23.0, 0.0));
857        assert_eq!(pt.idx, 9);
858        let pt = layout.hit_test_point(Point::new(26.0, 0.0));
859        assert_eq!(pt.idx, 10);
860        let pt = layout.hit_test_point(Point::new(29.0, 0.0));
861        assert_eq!(pt.idx, 10);
862        let pt = layout.hit_test_point(Point::new(32.0, 0.0));
863        assert_eq!(pt.idx, 10);
864        let pt = layout.hit_test_point(Point::new(35.5, 0.0));
865        assert_eq!(pt.idx, 14);
866        let pt = layout.hit_test_point(Point::new(38.0, 0.0));
867        assert_eq!(pt.idx, 14);
868        let pt = layout.hit_test_point(Point::new(40.0, 0.0));
869        assert_eq!(pt.idx, 14);
870    }
871
872    // NOTE brittle test
873    #[wasm_bindgen_test]
874    pub fn test_hit_test_point_complex_1() {
875        let (_window, context) = setup_ctx();
876        let mut text_layout = WebText::new(context);
877
878        // this input caused an infinite loop in the binary search when test position
879        // > 21.0 && < 28.0
880        //
881        // This corresponds to the char 'y' in the input.
882        let input = "tßßypi";
883
884        let font = text_layout.font_family("sans-serif").unwrap();
885        let layout = text_layout
886            .new_text_layout(input)
887            .font(font, 14.0)
888            .build()
889            .unwrap();
890        console::log_1(&format!("text pos 0: {:?}", layout.hit_test_text_position(0)).into()); // 0.0
891        console::log_1(&format!("text pos 1: {:?}", layout.hit_test_text_position(1)).into()); // 3.88...
892        console::log_1(&format!("text pos 2: {:?}", layout.hit_test_text_position(2)).into()); // 3.88...
893        console::log_1(&format!("text pos 3: {:?}", layout.hit_test_text_position(3)).into()); // 10.88...
894        console::log_1(&format!("text pos 4: {:?}", layout.hit_test_text_position(4)).into()); // 10.88...
895        console::log_1(&format!("text pos 5: {:?}", layout.hit_test_text_position(5)).into()); // 17.88...
896        console::log_1(&format!("text pos 6: {:?}", layout.hit_test_text_position(6)).into()); // 24.88...
897        console::log_1(&format!("text pos 7: {:?}", layout.hit_test_text_position(7)).into()); // 31.88...
898        console::log_1(&format!("text pos 8: {:?}", layout.hit_test_text_position(8)).into()); // 35.77..., end
899
900        let pt = layout.hit_test_point(Point::new(27.0, 0.0));
901        assert_eq!(pt.idx, 6);
902    }
903
904    #[wasm_bindgen_test]
905    fn test_multiline_hit_test_text_position_basic() {
906        let (_window, context) = setup_ctx();
907        let mut text_layout = WebText::new(context);
908
909        let input = "piet  text!";
910        let font = text_layout
911            .font_family("sans-serif") // change this for osx
912            .unwrap();
913
914        let layout = text_layout
915            .new_text_layout(&input[0..3])
916            .font(font.clone(), 15.0)
917            .max_width(30.0)
918            .build()
919            .unwrap();
920        let pie_width = layout.size().width;
921
922        let layout = text_layout
923            .new_text_layout(&input[0..4])
924            .font(font.clone(), 15.0)
925            .max_width(25.0)
926            .build()
927            .unwrap();
928        let piet_width = layout.size().width;
929
930        let layout = text_layout
931            .new_text_layout(&input[0..5])
932            .font(font.clone(), 15.0)
933            .max_width(30.0)
934            .build()
935            .unwrap();
936        let piet_space_width = layout.size().width;
937
938        // "text" should be on second line
939        let layout = text_layout
940            .new_text_layout(&input[6..10])
941            .font(font.clone(), 15.0)
942            .max_width(25.0)
943            .build()
944            .unwrap();
945        let text_width = layout.size().width;
946
947        let layout = text_layout
948            .new_text_layout(&input[6..9])
949            .font(font.clone(), 15.0)
950            .max_width(25.0)
951            .build()
952            .unwrap();
953        let tex_width = layout.size().width;
954
955        let layout = text_layout
956            .new_text_layout(&input[6..8])
957            .font(font.clone(), 15.0)
958            .max_width(25.0)
959            .build()
960            .unwrap();
961        let te_width = layout.size().width;
962
963        let layout = text_layout
964            .new_text_layout(&input[6..7])
965            .font(font.clone(), 15.0)
966            .max_width(25.0)
967            .build()
968            .unwrap();
969        let t_width = layout.size().width;
970
971        let full_layout = text_layout
972            .new_text_layout(input)
973            .font(font, 15.0)
974            .max_width(25.0)
975            .build()
976            .unwrap();
977
978        println!("lm: {:#?}", full_layout.line_metrics);
979        println!("layout width: {:#?}", full_layout.size().width);
980
981        println!("'pie': {pie_width}");
982        println!("'piet': {piet_width}");
983        println!("'piet ': {piet_space_width}");
984        println!("'text': {text_width}");
985        println!("'tex': {tex_width}");
986        println!("'te': {te_width}");
987        println!("'t': {t_width}");
988
989        // NOTE these heights are representative of baseline-to-baseline measures
990        let line_zero_baseline = 0.0;
991        let line_one_baseline = full_layout.line_metric(1).unwrap().height;
992
993        // these just test the x position of text positions on the second line
994        assert_close_to(
995            full_layout.hit_test_text_position(10).point.x,
996            text_width,
997            3.0,
998        );
999        assert_close_to(
1000            full_layout.hit_test_text_position(9).point.x,
1001            tex_width,
1002            3.0,
1003        );
1004        assert_close_to(full_layout.hit_test_text_position(8).point.x, te_width, 3.0);
1005        assert_close_to(full_layout.hit_test_text_position(7).point.x, t_width, 3.0);
1006        // This should be beginning of second line
1007        assert_close_to(full_layout.hit_test_text_position(6).point.x, 0.0, 3.0);
1008
1009        assert_close_to(
1010            full_layout.hit_test_text_position(3).point.x,
1011            pie_width,
1012            3.0,
1013        );
1014
1015        // This tests that trailing whitespace is included in the first line width.
1016        assert_close_to(
1017            full_layout.hit_test_text_position(5).point.x,
1018            piet_space_width,
1019            3.0,
1020        );
1021
1022        // These test y position of text positions on line 1 (0-index)
1023        assert_close_to(
1024            full_layout.hit_test_text_position(10).point.y,
1025            line_one_baseline,
1026            3.0,
1027        );
1028        assert_close_to(
1029            full_layout.hit_test_text_position(9).point.y,
1030            line_one_baseline,
1031            3.0,
1032        );
1033        assert_close_to(
1034            full_layout.hit_test_text_position(8).point.y,
1035            line_one_baseline,
1036            3.0,
1037        );
1038        assert_close_to(
1039            full_layout.hit_test_text_position(7).point.y,
1040            line_one_baseline,
1041            3.0,
1042        );
1043        assert_close_to(
1044            full_layout.hit_test_text_position(6).point.y,
1045            line_one_baseline,
1046            3.0,
1047        );
1048
1049        // this tests y position of 0 line
1050        assert_close_to(
1051            full_layout.hit_test_text_position(5).point.y,
1052            line_zero_baseline,
1053            3.0,
1054        );
1055        assert_close_to(
1056            full_layout.hit_test_text_position(4).point.y,
1057            line_zero_baseline,
1058            3.0,
1059        );
1060    }
1061
1062    // very basic testing that multiline works
1063    #[wasm_bindgen_test]
1064    fn test_multiline_hit_test_point_basic() {
1065        let input = "piet text most best";
1066
1067        let (_window, context) = setup_ctx();
1068        let mut text = WebText::new(context);
1069
1070        let font = text.font_family("sans-serif").unwrap();
1071        // this should break into four lines
1072        // Had to shift font in order to break at 4 lines (larger font than cairo, wider lines)
1073        let layout = text
1074            .new_text_layout(input)
1075            .font(font.clone(), 14.0)
1076            .max_width(30.0)
1077            .build()
1078            .unwrap();
1079        console::log_1(&format!("text pos 01: {:?}", layout.hit_test_text_position(0)).into()); // (0.0,0.0)
1080        console::log_1(&format!("text pos 06: {:?}", layout.hit_test_text_position(5)).into()); // (0.0, 16.8)
1081        console::log_1(&format!("text pos 11: {:?}", layout.hit_test_text_position(10)).into()); // (0.0, 33.6)
1082        console::log_1(&format!("text pos 16: {:?}", layout.hit_test_text_position(15)).into()); // (0.0, 50.4)
1083        console::log_1(&format!("lm 0: {:?}", layout.line_metric(0)).into());
1084        console::log_1(&format!("lm 1: {:?}", layout.line_metric(1)).into());
1085        console::log_1(&format!("lm 2: {:?}", layout.line_metric(2)).into());
1086        console::log_1(&format!("lm 3: {:?}", layout.line_metric(3)).into());
1087
1088        // approx 13.5 baseline, and 17 height
1089        let pt = layout.hit_test_point(Point::new(1.0, -1.0));
1090        assert_eq!(pt.idx, 0);
1091        assert!(pt.is_inside);
1092        let pt = layout.hit_test_point(Point::new(1.0, 00.0));
1093        assert_eq!(pt.idx, 0);
1094        let pt = layout.hit_test_point(Point::new(1.0, 04.0));
1095        assert_eq!(pt.idx, 5);
1096        let pt = layout.hit_test_point(Point::new(1.0, 21.0));
1097        assert_eq!(pt.idx, 10);
1098        let pt = layout.hit_test_point(Point::new(1.0, 38.0));
1099        assert_eq!(pt.idx, 15);
1100
1101        // over on y axis, but x still affects the text position
1102        let best_layout = text
1103            .new_text_layout("best")
1104            .font(font.clone(), 14.0)
1105            .build()
1106            .unwrap();
1107        console::log_1(&format!("layout width: {:#?}", best_layout.size().width).into()); // 22.55...
1108
1109        let pt = layout.hit_test_point(Point::new(1.0, 55.0));
1110        assert_eq!(pt.idx, 15);
1111        assert!(!pt.is_inside);
1112
1113        let pt = layout.hit_test_point(Point::new(25.0, 55.0));
1114        assert_eq!(pt.idx, 19);
1115        assert!(!pt.is_inside);
1116
1117        let pt = layout.hit_test_point(Point::new(27.0, 55.0));
1118        assert_eq!(pt.idx, 19);
1119        assert!(!pt.is_inside);
1120
1121        // under
1122        let piet_layout = text
1123            .new_text_layout("piet ")
1124            .font(font, 14.0)
1125            .build()
1126            .unwrap();
1127        console::log_1(&format!("layout width: {:#?}", piet_layout.size().width).into()); // 24.49...
1128
1129        let pt = layout.hit_test_point(Point::new(1.0, -14.0)); // under
1130        assert_eq!(pt.idx, 0);
1131        assert!(!pt.is_inside);
1132
1133        let pt = layout.hit_test_point(Point::new(25.0, -14.0)); // under
1134        assert_eq!(pt.idx, 5);
1135        assert!(!pt.is_inside);
1136
1137        let pt = layout.hit_test_point(Point::new(27.0, -14.0)); // under
1138        assert_eq!(pt.idx, 5);
1139        assert!(!pt.is_inside);
1140    }
1141}