azul_text_layout/
text_layout.rs

1//! Contains functions for breaking a string into words, calculate
2//! the positions of words / lines and do glyph positioning
3
4use azul_css::{LayoutSize, LayoutRect, LayoutPoint};
5pub use azul_core::{
6    app_resources::{
7        Words, Word, WordType, GlyphInfo, GlyphPosition,
8        ScaledWords, ScaledWord, WordIndex, GlyphIndex, LineLength, IndexOfLineBreak,
9        RemainingSpaceToRight, LineBreaks, WordPositions, LayoutedGlyphs,
10        ClusterIterator, ClusterInfo, FontMetrics,
11    },
12    display_list::GlyphInstance,
13    ui_solver::{
14        ResolvedTextLayoutOptions, TextLayoutOptions, InlineTextLayout,
15        DEFAULT_LINE_HEIGHT, DEFAULT_WORD_SPACING, DEFAULT_LETTER_SPACING, DEFAULT_TAB_WIDTH,
16    },
17};
18
19/// Whether the text overflows the parent rectangle, and if yes, by how many pixels,
20/// necessary for determining if / how to show a scrollbar + aligning / centering text.
21#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
22pub enum TextOverflow {
23    /// Text is overflowing, by how much (in pixels)?
24    IsOverflowing(f32),
25    /// Text is in bounds, how much space is available until the edge of the rectangle (in pixels)?
26    InBounds(f32),
27}
28
29/// Splits the text by whitespace into logical units (word, tab, return, whitespace).
30pub fn split_text_into_words(text: &str) -> Words {
31
32    use unicode_normalization::UnicodeNormalization;
33
34    // Necessary because we need to handle both \n and \r\n characters
35    // If we just look at the characters one-by-one, this wouldn't be possible.
36    let normalized_string = text.nfc().collect::<String>();
37    let normalized_chars = normalized_string.chars().collect::<Vec<char>>();
38
39    let mut words = Vec::new();
40
41    // Instead of storing the actual word, the word is only stored as an index instead,
42    // which reduces allocations and is important for later on introducing RTL text
43    // (where the position of the character data does not correspond to the actual glyph order).
44    let mut current_word_start = 0;
45    let mut last_char_idx = 0;
46    let mut last_char_was_whitespace = false;
47
48    for (ch_idx, ch) in normalized_chars.iter().enumerate() {
49
50        let ch = *ch;
51        let current_char_is_whitespace = ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
52
53        let should_push_delimiter = match ch {
54            ' ' => {
55                Some(Word {
56                    start: last_char_idx + 1,
57                    end: ch_idx + 1,
58                    word_type: WordType::Space
59                })
60            },
61            '\t' => {
62                Some(Word {
63                    start: last_char_idx + 1,
64                    end: ch_idx + 1,
65                    word_type: WordType::Tab
66                })
67            },
68            '\n' => {
69                Some(if normalized_chars[last_char_idx] == '\r' {
70                    // "\r\n" return
71                    Word {
72                        start: last_char_idx,
73                        end: ch_idx + 1,
74                        word_type: WordType::Return,
75                    }
76                } else {
77                    // "\n" return
78                    Word {
79                        start: last_char_idx + 1,
80                        end: ch_idx + 1,
81                        word_type: WordType::Return,
82                    }
83                })
84            },
85            _ => None,
86        };
87
88        // Character is a whitespace or the character is the last character in the text (end of text)
89        let should_push_word = if current_char_is_whitespace && !last_char_was_whitespace {
90            Some(Word {
91                start: current_word_start,
92                end: ch_idx,
93                word_type: WordType::Word
94            })
95        } else {
96            None
97        };
98
99        if current_char_is_whitespace {
100            current_word_start = ch_idx + 1;
101        }
102
103        let mut push_words = |arr: [Option<Word>;2]| {
104            words.extend(arr.iter().filter_map(|e| *e));
105        };
106
107        push_words([should_push_word, should_push_delimiter]);
108
109        last_char_was_whitespace = current_char_is_whitespace;
110        last_char_idx = ch_idx;
111    }
112
113    // Push the last word
114    if current_word_start != last_char_idx + 1 {
115        words.push(Word {
116            start: current_word_start,
117            end: normalized_chars.len(),
118            word_type: WordType::Word
119        });
120    }
121
122    // If the last item is a `Return`, remove it
123    if let Some(Word { word_type: WordType::Return, .. }) = words.last() {
124        words.pop();
125    }
126
127    Words {
128        items: words,
129        internal_str: normalized_string,
130        internal_chars: normalized_chars,
131    }
132}
133
134/// Takes a text broken into semantic items and a font instance and
135/// scales the font accordingly.
136pub fn words_to_scaled_words(
137    words: &Words,
138    font_bytes: &[u8],
139    font_index: u32,
140    font_metrics: FontMetrics,
141    font_size_px: f32,
142) -> ScaledWords {
143
144    use std::mem;
145    use std::char;
146    use crate::text_shaping::{self, HB_SCALE_FACTOR, HbBuffer, HbFont, HbScaledFont};
147
148    let hb_font = HbFont::from_bytes(font_bytes, font_index);
149    let hb_scaled_font = HbScaledFont::from_font(&hb_font, font_size_px);
150
151    // Get the dimensions of the space glyph
152    let hb_space_buffer = HbBuffer::from_str(" ");
153    let hb_shaped_space = text_shaping::shape_word_hb(&hb_space_buffer, &hb_scaled_font);
154    let space_advance_px = hb_shaped_space.glyph_positions[0].x_advance as f32 / HB_SCALE_FACTOR;
155    let space_codepoint = hb_shaped_space.glyph_infos[0].codepoint;
156
157    let internal_str = words.internal_str.replace(char::is_whitespace, " ");
158
159    let hb_buffer_entire_paragraph = HbBuffer::from_str(&internal_str);
160    let hb_shaped_entire_paragraph = text_shaping::shape_word_hb(&hb_buffer_entire_paragraph, &hb_scaled_font);
161
162    let mut shaped_word_positions = Vec::<Vec<GlyphPosition>>::new();
163    let mut shaped_word_infos = Vec::<Vec<GlyphInfo>>::new();
164    let mut current_word_positions = Vec::new();
165    let mut current_word_infos = Vec::new();
166
167    for i in 0..hb_shaped_entire_paragraph.glyph_positions.len() {
168        let glyph_info = hb_shaped_entire_paragraph.glyph_infos[i];
169        let glyph_position = hb_shaped_entire_paragraph.glyph_positions[i];
170
171        let is_space = glyph_info.codepoint == space_codepoint;
172        if is_space {
173            shaped_word_positions.push(current_word_positions.clone());
174            shaped_word_infos.push(current_word_infos.clone());
175            current_word_positions.clear();
176            current_word_infos.clear();
177        } else {
178            // azul-core::GlyphInfo and hb_position_t have the same size / layout
179            // (both are repr(C)), so it's safe to just transmute them here
180            current_word_positions.push(unsafe { mem::transmute(glyph_position) });
181            current_word_infos.push(unsafe { mem::transmute(glyph_info) });
182        }
183    }
184
185    if !current_word_positions.is_empty() {
186        shaped_word_positions.push(current_word_positions);
187        shaped_word_infos.push(current_word_infos);
188    }
189
190    let mut longest_word_width = 0.0_f32;
191
192    let scaled_words = words.items.iter()
193        .filter(|w| w.word_type == WordType::Word)
194        .enumerate()
195        .filter_map(|(word_idx, _)| {
196
197            let hb_glyph_positions = shaped_word_positions.get(word_idx)?.clone();
198            let hb_glyph_infos = shaped_word_infos.get(word_idx)?.clone();
199            let hb_word_width = text_shaping::get_word_visual_width_hb(&hb_glyph_positions);
200
201            longest_word_width = longest_word_width.max(hb_word_width.abs());
202
203            Some(ScaledWord {
204                glyph_infos: hb_glyph_infos,
205                glyph_positions: hb_glyph_positions,
206                word_width: hb_word_width,
207            })
208        }).collect();
209
210    ScaledWords {
211        font_size_px,
212        font_metrics,
213        baseline_px: font_size_px, // TODO!
214        items: scaled_words,
215        longest_word_width: longest_word_width,
216        space_advance_px,
217        space_codepoint,
218    }
219}
220
221/// Positions the words on the screen (does not layout any glyph positions!), necessary for estimating
222/// the intrinsic width + height of the text content.
223pub fn position_words(
224    words: &Words,
225    scaled_words: &ScaledWords,
226    text_layout_options: &ResolvedTextLayoutOptions,
227) -> WordPositions {
228
229    use self::WordType::*;
230    use std::f32;
231
232    let font_size_px = text_layout_options.font_size_px;
233    let space_advance = scaled_words.space_advance_px;
234    let word_spacing_px = space_advance * text_layout_options.word_spacing.unwrap_or(DEFAULT_WORD_SPACING);
235    let line_height_px = space_advance * text_layout_options.line_height.unwrap_or(DEFAULT_LINE_HEIGHT);
236    let tab_width_px = space_advance * text_layout_options.tab_width.unwrap_or(DEFAULT_TAB_WIDTH);
237
238    let mut line_breaks = Vec::new();
239    let mut word_positions = Vec::new();
240
241    let mut line_number = 0;
242    let mut line_caret_x = 0.0;
243    let mut current_word_idx = 0;
244
245    macro_rules! advance_caret {($line_caret_x:expr) => ({
246        let caret_intersection = caret_intersects_with_holes(
247            $line_caret_x,
248            line_number,
249            font_size_px,
250            line_height_px,
251            &text_layout_options.holes[..],
252            text_layout_options.max_horizontal_width,
253        );
254
255        if let LineCaretIntersection::PushCaretOntoNextLine(_, _) = caret_intersection {
256             line_breaks.push((current_word_idx, line_caret_x));
257        }
258
259        // Correct and advance the line caret position
260        advance_caret(
261            &mut $line_caret_x,
262            &mut line_number,
263            caret_intersection,
264        );
265    })}
266
267    advance_caret!(line_caret_x);
268
269    if let Some(leading) = text_layout_options.leading {
270        line_caret_x += leading;
271        advance_caret!(line_caret_x);
272    }
273
274    // NOTE: word_idx increases only on words, not on other symbols!
275    let mut word_idx = 0;
276
277    macro_rules! handle_word {() => ({
278
279        let scaled_word = match scaled_words.items.get(word_idx) {
280            Some(s) => s,
281            None => continue,
282        };
283
284        let reserved_letter_spacing_px = match text_layout_options.letter_spacing {
285            None => 0.0,
286            Some(spacing_multiplier) => spacing_multiplier * scaled_word.number_of_clusters().saturating_sub(1) as f32,
287        };
288
289        // Calculate where the caret would be for the next word
290        let word_advance_x = scaled_word.word_width + reserved_letter_spacing_px;
291
292        let mut new_caret_x = line_caret_x + word_advance_x;
293
294        // NOTE: Slightly modified "advance_caret!(new_caret_x);" - due to line breaking behaviour
295
296        let caret_intersection = caret_intersects_with_holes(
297            new_caret_x,
298            line_number,
299            font_size_px,
300            line_height_px,
301            &text_layout_options.holes,
302            text_layout_options.max_horizontal_width,
303        );
304
305        let mut is_line_break = false;
306        if let LineCaretIntersection::PushCaretOntoNextLine(_, _) = caret_intersection {
307            line_breaks.push((current_word_idx, line_caret_x));
308            is_line_break = true;
309        }
310
311        if !is_line_break {
312            let line_caret_y = get_line_y_position(line_number, font_size_px, line_height_px);
313            word_positions.push(LayoutPoint::new(line_caret_x, line_caret_y));
314        }
315
316        // Correct and advance the line caret position
317        advance_caret(
318            &mut new_caret_x,
319            &mut line_number,
320            caret_intersection,
321        );
322
323        line_caret_x = new_caret_x;
324
325        // If there was a line break, the position needs to be determined after the line break happened
326        if is_line_break {
327            let line_caret_y = get_line_y_position(line_number, font_size_px, line_height_px);
328            word_positions.push(LayoutPoint::new(line_caret_x, line_caret_y));
329            // important! - if the word is pushed onto the next line, the caret has to be
330            // advanced by that words width!
331            line_caret_x += word_advance_x;
332        }
333
334        // NOTE: Word index is increased before pushing, since word indices are 1-indexed
335        // (so that paragraphs can be selected via "(0..word_index)").
336        word_idx += 1;
337        current_word_idx = word_idx;
338    })}
339
340    // The last word is a bit special: Any text must have at least one line break!
341    for word in words.items.iter().take(words.items.len().saturating_sub(1)) {
342        match word.word_type {
343            Word => {
344                handle_word!();
345            },
346            Return => {
347                line_breaks.push((current_word_idx, line_caret_x));
348                line_number += 1;
349                let mut new_caret_x = 0.0;
350                advance_caret!(new_caret_x);
351                line_caret_x = new_caret_x;
352            },
353            Space => {
354                let mut new_caret_x = line_caret_x + word_spacing_px;
355                advance_caret!(new_caret_x);
356                line_caret_x = new_caret_x;
357            },
358            Tab => {
359                let mut new_caret_x = line_caret_x + word_spacing_px + tab_width_px;
360                advance_caret!(new_caret_x);
361                line_caret_x = new_caret_x;
362            },
363        }
364    }
365
366    // Handle the last word, but ignore any last Return, Space or Tab characters
367    for word in &words.items[words.items.len().saturating_sub(1)..] {
368        if word.word_type == Word {
369            handle_word!();
370        }
371        line_breaks.push((current_word_idx, line_caret_x));
372    }
373
374    let trailing = line_caret_x;
375    let number_of_lines = line_number + 1;
376    let number_of_words = current_word_idx + 1;
377
378    let longest_line_width = line_breaks.iter().map(|(_word_idx, line_length)| *line_length).fold(0.0_f32, f32::max);
379    let content_size_y = get_line_y_position(line_number, font_size_px, line_height_px);
380    let content_size_x = text_layout_options.max_horizontal_width.unwrap_or(longest_line_width);
381    let content_size = LayoutSize::new(content_size_x, content_size_y);
382
383    WordPositions {
384        text_layout_options: text_layout_options.clone(),
385        trailing,
386        number_of_words,
387        number_of_lines,
388        content_size,
389        word_positions,
390        line_breaks,
391    }
392}
393
394/// Returns the (left-aligned!) bounding boxes of the indidividual text lines
395pub fn word_positions_to_inline_text_layout(
396    word_positions: &WordPositions,
397    scaled_words: &ScaledWords
398) -> InlineTextLayout {
399
400    use azul_core::ui_solver::InlineTextLine;
401
402    let font_size_px = word_positions.text_layout_options.font_size_px;
403    let regular_line_height = scaled_words.font_metrics.get_height(font_size_px);
404    let space_advance = scaled_words.space_advance_px;
405    let line_height_px = space_advance * word_positions.text_layout_options.line_height.unwrap_or(DEFAULT_LINE_HEIGHT);
406
407    let mut last_word_index = 0;
408
409    InlineTextLayout {
410        lines: word_positions.line_breaks
411            .iter()
412            .enumerate()
413            .map(|(line_number, (word_idx, line_length))| {
414                let start_word_idx = last_word_index;
415                let line = InlineTextLine {
416                    bounds: LayoutRect {
417                        origin: LayoutPoint { x: 0.0, y: get_line_y_position(line_number, regular_line_height, line_height_px) },
418                        size: LayoutSize { width: *line_length, height: regular_line_height },
419                    },
420                    word_start: start_word_idx,
421                    word_end: *word_idx,
422                };
423                last_word_index = *word_idx;
424                line
425        }).collect(),
426    }
427}
428
429pub fn get_layouted_glyphs(
430    word_positions: &WordPositions,
431    scaled_words: &ScaledWords,
432    inline_text_layout: &InlineTextLayout,
433) -> LayoutedGlyphs {
434
435    use crate::text_shaping;
436
437    let letter_spacing_px = word_positions.text_layout_options.letter_spacing.unwrap_or(0.0);
438    let mut all_glyphs = Vec::with_capacity(scaled_words.items.len());
439    let baseline_px = scaled_words.font_metrics.get_ascender(scaled_words.font_size_px);
440
441    for line in inline_text_layout.lines.iter() {
442
443        let line_x = line.bounds.origin.x;
444        let line_y = line.bounds.origin.y - (line.bounds.size.height - baseline_px); // bottom left corner of the glyph (baseline)
445
446        let scaled_words_in_this_line = &scaled_words.items[line.word_start..line.word_end];
447        let word_positions_in_this_line = &word_positions.word_positions[line.word_start..line.word_end];
448
449        for (scaled_word, word_position) in scaled_words_in_this_line.iter().zip(word_positions_in_this_line.iter()) {
450            let mut glyphs = text_shaping::get_glyph_instances_hb(&scaled_word.glyph_infos, &scaled_word.glyph_positions);
451            for (glyph, cluster_info) in glyphs.iter_mut().zip(scaled_word.cluster_iter()) {
452                glyph.point.x += line_x + word_position.x + (letter_spacing_px * cluster_info.cluster_idx as f32);
453                glyph.point.y += line_y;
454            }
455
456            all_glyphs.append(&mut glyphs);
457        }
458    }
459
460    LayoutedGlyphs { glyphs: all_glyphs }
461}
462
463pub fn word_item_is_return(item: &Word) -> bool {
464    item.word_type == WordType::Return
465}
466
467/// For a given line number (**NOTE: 0-indexed!**), calculates the Y
468/// position of the bottom left corner
469pub fn get_line_y_position(line_number: usize, font_size_px: f32, line_height_px: f32) -> f32 {
470    ((font_size_px + line_height_px) * line_number as f32) + font_size_px
471}
472
473#[derive(Debug, Copy, Clone, PartialOrd, PartialEq)]
474pub enum LineCaretIntersection {
475    /// OK: Caret does not interset any elements
476    NoIntersection,
477    /// In order to not intersect with any holes, the caret needs to
478    /// be advanced to the position x, but can stay on the same line.
479    AdvanceCaretTo(f32),
480    /// Caret needs to advance X number of lines and be positioned
481    /// with a leading of x
482    PushCaretOntoNextLine(usize, f32),
483}
484
485/// Check if the caret intersects with any holes and if yes, if the cursor should move to a new line.
486///
487/// # Inputs
488///
489/// - `line_caret_x`: The current horizontal caret position
490/// - `line_number`: The current line number
491/// - `holes`: Whether the text should respect any rectangular regions
492///    where the text can't flow (preparation for inline / float layout).
493/// - `max_width`: Does the text have a restriction on how wide it can be (in pixels)
494pub fn caret_intersects_with_holes(
495    line_caret_x: f32,
496    line_number: usize,
497    font_size_px: f32,
498    line_height_px: f32,
499    holes: &[LayoutRect],
500    max_width: Option<f32>,
501) -> LineCaretIntersection {
502
503    let mut new_line_caret_x = None;
504    let mut line_advance = 0;
505
506    // If the caret is outside of the max_width, move it to the start of a new line
507    if let Some(max_width) = max_width {
508        if line_caret_x > max_width {
509            new_line_caret_x = Some(0.0);
510            line_advance += 1;
511        }
512    }
513
514    for hole in holes {
515
516        let mut should_move_caret = false;
517        let mut current_line_advance = 0;
518        let mut new_line_number = line_number + current_line_advance;
519        let mut current_caret = LayoutPoint::new(
520            new_line_caret_x.unwrap_or(line_caret_x),
521            get_line_y_position(new_line_number, font_size_px, line_height_px)
522        );
523
524        // NOTE: holes need to be sorted by Y origin (from smallest to largest Y),
525        // and be sorted from left to right
526        while hole.contains(&current_caret) {
527            should_move_caret = true;
528            if let Some(max_width) = max_width {
529                if hole.origin.x + hole.size.width >= max_width {
530                    // Need to break the line here
531                    current_line_advance += 1;
532                    new_line_number = line_number + current_line_advance;
533                    current_caret = LayoutPoint::new(
534                        new_line_caret_x.unwrap_or(line_caret_x),
535                        get_line_y_position(new_line_number, font_size_px, line_height_px)
536                    );
537                } else {
538                    new_line_number = line_number + current_line_advance;
539                    current_caret = LayoutPoint::new(
540                        hole.origin.x + hole.size.width,
541                        get_line_y_position(new_line_number, font_size_px, line_height_px)
542                    );
543                }
544            } else {
545                // No max width, so no need to break the line, move the caret to the right side of the hole
546                new_line_number = line_number + current_line_advance;
547                current_caret = LayoutPoint::new(
548                    hole.origin.x + hole.size.width,
549                    get_line_y_position(new_line_number, font_size_px, line_height_px)
550                );
551            }
552        }
553
554        if should_move_caret {
555            new_line_caret_x = Some(current_caret.x);
556            line_advance += current_line_advance;
557        }
558    }
559
560    if let Some(new_line_caret_x) = new_line_caret_x {
561        if line_advance == 0 {
562            LineCaretIntersection::AdvanceCaretTo(new_line_caret_x)
563        } else {
564            LineCaretIntersection::PushCaretOntoNextLine(line_advance, new_line_caret_x)
565        }
566    } else {
567        LineCaretIntersection::NoIntersection
568    }
569}
570
571pub fn advance_caret(caret: &mut f32, line_number: &mut usize, intersection: LineCaretIntersection) {
572    use self::LineCaretIntersection::*;
573    match intersection {
574        NoIntersection => { },
575        AdvanceCaretTo(x) => { *caret = x; },
576        PushCaretOntoNextLine(num_lines, x) => { *line_number += num_lines; *caret = x; },
577    }
578}
579
580#[test]
581fn test_split_words() {
582
583    fn print_words(w: &Words) {
584        println!("-- string: {:?}", w.get_str());
585        for item in &w.items {
586            println!("{:?} - ({}..{}) = {:?}", w.get_substr(item), item.start, item.end, item.word_type);
587        }
588    }
589
590    fn string_to_vec(s: String) -> Vec<char> {
591        s.chars().collect()
592    }
593
594    fn assert_words(expected: &Words, got_words: &Words) {
595        for (idx, expected_word) in expected.items.iter().enumerate() {
596            let got = got_words.items.get(idx);
597            if got != Some(expected_word) {
598                println!("expected: ");
599                print_words(expected);
600                println!("got: ");
601                print_words(got_words);
602                panic!("Expected word idx {} - expected: {:#?}, got: {:#?}", idx, Some(expected_word), got);
603            }
604        }
605    }
606
607    let ascii_str = String::from("abc\tdef  \nghi\r\njkl");
608    let words_ascii = split_text_into_words(&ascii_str);
609    let words_ascii_expected = Words {
610        internal_str: ascii_str.clone(),
611        internal_chars: string_to_vec(ascii_str),
612        items: vec![
613            Word { start: 0,    end: 3,     word_type: WordType::Word     }, // "abc" - (0..3) = Word
614            Word { start: 3,    end: 4,     word_type: WordType::Tab      }, // "\t" - (3..4) = Tab
615            Word { start: 4,    end: 7,     word_type: WordType::Word     }, // "def" - (4..7) = Word
616            Word { start: 7,    end: 8,     word_type: WordType::Space    }, // " " - (7..8) = Space
617            Word { start: 8,    end: 9,     word_type: WordType::Space    }, // " " - (8..9) = Space
618            Word { start: 9,    end: 10,    word_type: WordType::Return   }, // "\n" - (9..10) = Return
619            Word { start: 10,   end: 13,    word_type: WordType::Word     }, // "ghi" - (10..13) = Word
620            Word { start: 13,   end: 15,    word_type: WordType::Return   }, // "\r\n" - (13..15) = Return
621            Word { start: 15,   end: 18,    word_type: WordType::Word     }, // "jkl" - (15..18) = Word
622        ],
623    };
624
625    assert_words(&words_ascii_expected, &words_ascii);
626
627    let unicode_str = String::from("㌊㌋㌌㌍㌎㌏㌐㌑ ㌒㌓㌔㌕㌖㌗");
628    let words_unicode = split_text_into_words(&unicode_str);
629    let words_unicode_expected = Words {
630        internal_str: unicode_str.clone(),
631        internal_chars: string_to_vec(unicode_str),
632        items: vec![
633            Word { start: 0,        end: 8,         word_type: WordType::Word   }, // "㌊㌋㌌㌍㌎㌏㌐㌑"
634            Word { start: 8,        end: 9,         word_type: WordType::Space  }, // " "
635            Word { start: 9,        end: 15,        word_type: WordType::Word   }, // "㌒㌓㌔㌕㌖㌗"
636        ],
637    };
638
639    assert_words(&words_unicode_expected, &words_unicode);
640
641    let single_str = String::from("A");
642    let words_single_str = split_text_into_words(&single_str);
643    let words_single_str_expected = Words {
644        internal_str: single_str.clone(),
645        internal_chars: string_to_vec(single_str),
646        items: vec![
647            Word { start: 0,        end: 1,         word_type: WordType::Word   }, // "A"
648        ],
649    };
650
651    assert_words(&words_single_str_expected, &words_single_str);
652}
653
654#[test]
655fn test_get_line_y_position() {
656
657    assert_eq!(get_line_y_position(0, 20.0, 0.0), 20.0);
658    assert_eq!(get_line_y_position(1, 20.0, 0.0), 40.0);
659    assert_eq!(get_line_y_position(2, 20.0, 0.0), 60.0);
660
661    // lines:
662    // 0 - height 20, padding 5 = 20.0 (padding is for the next line)
663    // 1 - height 20, padding 5 = 45.0 ( = 20 + 20 + 5)
664    // 2 - height 20, padding 5 = 70.0 ( = 20 + 20 + 5 + 20 + 5)
665    assert_eq!(get_line_y_position(0, 20.0, 5.0), 20.0);
666    assert_eq!(get_line_y_position(1, 20.0, 5.0), 45.0);
667    assert_eq!(get_line_y_position(2, 20.0, 5.0), 70.0);
668}
669
670// Scenario 1:
671//
672// +---------+
673// |+ ------>|+
674// |         |
675// +---------+
676// rectangle: 100x200
677// max-width: none, line-height 1.0, font-size: 20
678// cursor is at: 0x, 20y
679// expect cursor to advance to 100x, 20y
680//
681#[test]
682fn test_caret_intersects_with_holes_1() {
683    let line_caret_x = 0.0;
684    let line_number = 0;
685    let font_size_px = 20.0;
686    let line_height_px = 0.0;
687    let max_width = None;
688    let holes = vec![LayoutRect::new(LayoutPoint::new(0.0, 0.0), LayoutSize::new(200.0, 100.0))];
689
690    let result = caret_intersects_with_holes(
691        line_caret_x,
692        line_number,
693        font_size_px,
694        line_height_px,
695        &holes,
696        max_width,
697    );
698
699    assert_eq!(result, LineCaretIntersection::AdvanceCaretTo(200.0));
700}
701
702// Scenario 2:
703//
704// +---------+
705// |+ -----> |
706// |-------> |
707// |---------|
708// |+        |
709// |         |
710// +---------+
711// rectangle: 100x200
712// max-width: 200px, line-height 1.0, font-size: 20
713// cursor is at: 0x, 20y
714// expect cursor to advance to 0x, 100y (+= 4 lines)
715//
716#[test]
717fn test_caret_intersects_with_holes_2() {
718    let line_caret_x = 0.0;
719    let line_number = 0;
720    let font_size_px = 20.0;
721    let line_height_px = 0.0;
722    let max_width = Some(200.0);
723    let holes = vec![LayoutRect::new(LayoutPoint::new(0.0, 0.0), LayoutSize::new(200.0, 100.0))];
724
725    let result = caret_intersects_with_holes(
726        line_caret_x,
727        line_number,
728        font_size_px,
729        line_height_px,
730        &holes,
731        max_width,
732    );
733
734    assert_eq!(result, LineCaretIntersection::PushCaretOntoNextLine(4, 0.0));
735}
736
737// Scenario 3:
738//
739// +----------------+
740// |      |         |  +----->
741// |------->+       |
742// |------+         |
743// |                |
744// |                |
745// +----------------+
746// rectangle: 100x200
747// max-width: 400px, line-height 1.0, font-size: 20
748// cursor is at: 450x, 20y
749// expect cursor to advance to 200x, 40y (+= 1 lines, leading of 200px)
750//
751#[test]
752fn test_caret_intersects_with_holes_3() {
753    let line_caret_x = 450.0;
754    let line_number = 0;
755    let font_size_px = 20.0;
756    let line_height_px = 0.0;
757    let max_width = Some(400.0);
758    let holes = vec![LayoutRect::new(LayoutPoint::new(0.0, 0.0), LayoutSize::new(200.0, 100.0))];
759
760    let result = caret_intersects_with_holes(
761        line_caret_x,
762        line_number,
763        font_size_px,
764        line_height_px,
765        &holes,
766        max_width,
767    );
768
769    assert_eq!(result, LineCaretIntersection::PushCaretOntoNextLine(1, 200.0));
770}
771
772// Scenario 4:
773//
774// +----------------+
775// | +   +------+   |
776// |     |      |   |
777// |     |      |   |
778// |     +------+   |
779// |                |
780// +----------------+
781// rectangle: 100x200 @ 80.0x, 20.0y
782// max-width: 400px, line-height 1.0, font-size: 20
783// cursor is at: 40x, 20y
784// expect cursor to not advance at all
785//
786#[test]
787fn test_caret_intersects_with_holes_4() {
788    let line_caret_x = 40.0;
789    let line_number = 0;
790    let font_size_px = 20.0;
791    let line_height_px = 0.0;
792    let max_width = Some(400.0);
793    let holes = vec![LayoutRect::new(LayoutPoint::new(80.0, 20.0), LayoutSize::new(200.0, 100.0))];
794
795    let result = caret_intersects_with_holes(
796        line_caret_x,
797        line_number,
798        font_size_px,
799        line_height_px,
800        &holes,
801        max_width,
802    );
803
804    assert_eq!(result, LineCaretIntersection::NoIntersection);
805}