floem_renderer/text/
layout.rs

1use std::{ops::Range, sync::LazyLock};
2
3use crate::text::AttrsList;
4use cosmic_text::{
5    Affinity, Align, Buffer, BufferLine, Cursor, FontSystem, LayoutCursor, LayoutGlyph, LineEnding,
6    LineIter, Metrics, Scroll, Shaping, Wrap,
7};
8use parking_lot::Mutex;
9use peniko::kurbo::{Point, Size};
10use unicode_segmentation::UnicodeSegmentation;
11
12pub static FONT_SYSTEM: LazyLock<Mutex<FontSystem>> = LazyLock::new(|| {
13    let mut font_system = FontSystem::new();
14    #[cfg(target_os = "macos")]
15    font_system.db_mut().set_sans_serif_family("Helvetica Neue");
16    #[cfg(target_os = "windows")]
17    font_system.db_mut().set_sans_serif_family("Segoe UI");
18    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
19    font_system.db_mut().set_sans_serif_family("Noto Sans");
20    Mutex::new(font_system)
21});
22
23/// A line of visible text for rendering
24#[derive(Debug)]
25pub struct LayoutRun<'a> {
26    /// The index of the original text line
27    pub line_i: usize,
28    /// The original text line
29    pub text: &'a str,
30    /// True if the original paragraph direction is RTL
31    pub rtl: bool,
32    /// The array of layout glyphs to draw
33    pub glyphs: &'a [LayoutGlyph],
34    /// Maximum ascent of the glyphs in line
35    pub max_ascent: f32,
36    /// Maximum descent of the glyphs in line
37    pub max_descent: f32,
38    /// Y offset to baseline of line
39    pub line_y: f32,
40    /// Y offset to top of line
41    pub line_top: f32,
42    /// Y offset to next line
43    pub line_height: f32,
44    /// Width of line
45    pub line_w: f32,
46}
47
48impl LayoutRun<'_> {
49    /// Return the pixel span `Some((x_left, x_width))` of the highlighted area between `cursor_start`
50    /// and `cursor_end` within this run, or None if the cursor range does not intersect this run.
51    /// This may return widths of zero if `cursor_start == cursor_end`, if the run is empty, or if the
52    /// region's left start boundary is the same as the cursor's end boundary or vice versa.
53    pub fn highlight(&self, cursor_start: Cursor, cursor_end: Cursor) -> Option<(f32, f32)> {
54        let mut x_start = None;
55        let mut x_end = None;
56        let rtl_factor = if self.rtl { 1. } else { 0. };
57        let ltr_factor = 1. - rtl_factor;
58        for glyph in self.glyphs.iter() {
59            let cursor = self.cursor_from_glyph_left(glyph);
60            if cursor >= cursor_start && cursor <= cursor_end {
61                if x_start.is_none() {
62                    x_start = Some(glyph.x + glyph.w * rtl_factor);
63                }
64                x_end = Some(glyph.x + glyph.w * rtl_factor);
65            }
66            let cursor = self.cursor_from_glyph_right(glyph);
67            if cursor >= cursor_start && cursor <= cursor_end {
68                if x_start.is_none() {
69                    x_start = Some(glyph.x + glyph.w * ltr_factor);
70                }
71                x_end = Some(glyph.x + glyph.w * ltr_factor);
72            }
73        }
74        if let Some(x_start) = x_start {
75            let x_end = x_end.expect("end of cursor not found");
76            let (x_start, x_end) = if x_start < x_end {
77                (x_start, x_end)
78            } else {
79                (x_end, x_start)
80            };
81            Some((x_start, x_end - x_start))
82        } else {
83            None
84        }
85    }
86
87    fn cursor_from_glyph_left(&self, glyph: &LayoutGlyph) -> Cursor {
88        if self.rtl {
89            Cursor::new_with_affinity(self.line_i, glyph.end, Affinity::Before)
90        } else {
91            Cursor::new_with_affinity(self.line_i, glyph.start, Affinity::After)
92        }
93    }
94
95    pub fn cursor_from_glyph_right(&self, glyph: &LayoutGlyph) -> Cursor {
96        if self.rtl {
97            Cursor::new_with_affinity(self.line_i, glyph.start, Affinity::After)
98        } else {
99            Cursor::new_with_affinity(self.line_i, glyph.end, Affinity::Before)
100        }
101    }
102}
103
104/// An iterator of visible text lines, see [`LayoutRun`]
105#[derive(Debug)]
106pub struct LayoutRunIter<'b> {
107    text_layout: &'b TextLayout,
108    line_i: usize,
109    layout_i: usize,
110    total_height: f32,
111    line_top: f32,
112}
113
114impl<'b> LayoutRunIter<'b> {
115    pub fn new(text_layout: &'b TextLayout) -> Self {
116        Self {
117            text_layout,
118            line_i: text_layout.buffer.scroll().line,
119            layout_i: 0,
120            total_height: 0.0,
121            line_top: 0.0,
122        }
123    }
124}
125
126impl<'b> Iterator for LayoutRunIter<'b> {
127    type Item = LayoutRun<'b>;
128
129    fn next(&mut self) -> Option<Self::Item> {
130        while let Some(line) = self.text_layout.buffer.lines.get(self.line_i) {
131            let shape = line.shape_opt()?;
132            let layout = line.layout_opt()?;
133            while let Some(layout_line) = layout.get(self.layout_i) {
134                self.layout_i += 1;
135
136                let line_height = layout_line
137                    .line_height_opt
138                    .unwrap_or(self.text_layout.buffer.metrics().line_height);
139                self.total_height += line_height;
140
141                let line_top = self.line_top - self.text_layout.buffer.scroll().vertical;
142                let glyph_height = layout_line.max_ascent + layout_line.max_descent;
143                let centering_offset = (line_height - glyph_height) / 2.0;
144                let line_y = line_top + centering_offset + layout_line.max_ascent;
145                if let Some(height) = self.text_layout.height_opt {
146                    if line_y > height {
147                        return None;
148                    }
149                }
150                self.line_top += line_height;
151                if line_y < 0.0 {
152                    continue;
153                }
154
155                return Some(LayoutRun {
156                    line_i: self.line_i,
157                    text: line.text(),
158                    rtl: shape.rtl,
159                    glyphs: &layout_line.glyphs,
160                    max_ascent: layout_line.max_ascent,
161                    max_descent: layout_line.max_descent,
162                    line_y,
163                    line_top,
164                    line_height,
165                    line_w: layout_line.w,
166                });
167            }
168            self.line_i += 1;
169            self.layout_i = 0;
170        }
171
172        None
173    }
174}
175
176pub struct HitPosition {
177    /// Text line the cursor is on
178    pub line: usize,
179    /// Point of the cursor
180    pub point: Point,
181    /// ascent of glyph
182    pub glyph_ascent: f64,
183    /// descent of glyph
184    pub glyph_descent: f64,
185}
186
187pub struct HitPoint {
188    /// Text line the cursor is on
189    pub line: usize,
190    /// First-byte-index of glyph at cursor (will insert behind this glyph)
191    pub index: usize,
192    /// Whether or not the point was inside the bounds of the layout object.
193    ///
194    /// A click outside the layout object will still resolve to a position in the
195    /// text; for instance a click to the right edge of a line will resolve to the
196    /// end of that line, and a click below the last line will resolve to a
197    /// position in that line.
198    pub is_inside: bool,
199    pub affinity: Affinity,
200}
201
202#[derive(Clone, Debug)]
203pub struct TextLayout {
204    buffer: Buffer,
205    lines_range: Vec<Range<usize>>,
206    width_opt: Option<f32>,
207    height_opt: Option<f32>,
208}
209
210impl Default for TextLayout {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl TextLayout {
217    pub fn new() -> Self {
218        TextLayout {
219            buffer: Buffer::new_empty(Metrics::new(16.0, 16.0)),
220            lines_range: Vec::new(),
221            width_opt: None,
222            height_opt: None,
223        }
224    }
225
226    pub fn new_with_text(text: &str, attrs_list: AttrsList, align: Option<Align>) -> Self {
227        let mut layout = Self::new();
228        layout.set_text(text, attrs_list, align);
229        layout
230    }
231
232    pub fn set_text(&mut self, text: &str, attrs_list: AttrsList, align: Option<Align>) {
233        self.buffer.lines.clear();
234        self.lines_range.clear();
235        let mut attrs_list = attrs_list.0;
236        for (range, ending) in LineIter::new(text) {
237            self.lines_range.push(range.clone());
238            let line_text = &text[range];
239            let new_attrs = attrs_list
240                .clone()
241                .split_off(line_text.len() + ending.as_str().len());
242            let mut line =
243                BufferLine::new(line_text, ending, attrs_list.clone(), Shaping::Advanced);
244            line.set_align(align);
245            self.buffer.lines.push(line);
246            attrs_list = new_attrs;
247        }
248        if self.buffer.lines.is_empty() {
249            let mut line =
250                BufferLine::new("", LineEnding::default(), attrs_list, Shaping::Advanced);
251            line.set_align(align);
252            self.buffer.lines.push(line);
253            self.lines_range.push(0..0)
254        }
255        self.buffer.set_scroll(Scroll::default());
256
257        let mut font_system = FONT_SYSTEM.lock();
258
259        // two-pass layout for alignment to work properly
260        let needs_two_pass =
261            align.is_some() && align != Some(Align::Left) && self.width_opt.is_none();
262        if needs_two_pass {
263            // first pass: shape and layout without width constraint to measure natural width
264            self.buffer.shape_until_scroll(&mut font_system, false);
265
266            // measure the actual width
267            let measured_width = self
268                .buffer
269                .layout_runs()
270                .fold(0.0f32, |width, run| width.max(run.line_w));
271
272            // second pass: set the measured width and layout again
273            if measured_width > 0.0 {
274                self.buffer
275                    .set_size(&mut font_system, Some(measured_width), self.height_opt);
276                // shape again after size change
277                self.buffer.shape_until_scroll(&mut font_system, false);
278            }
279        } else {
280            // For left-aligned text, single pass is sufficient
281            self.buffer.shape_until_scroll(&mut font_system, false);
282        }
283    }
284
285    pub fn set_wrap(&mut self, wrap: Wrap) {
286        let mut font_system = FONT_SYSTEM.lock();
287        self.buffer.set_wrap(&mut font_system, wrap);
288    }
289
290    pub fn set_tab_width(&mut self, tab_width: usize) {
291        let mut font_system = FONT_SYSTEM.lock();
292        self.buffer
293            .set_tab_width(&mut font_system, tab_width as u16);
294    }
295
296    pub fn set_size(&mut self, width: f32, height: f32) {
297        let mut font_system = FONT_SYSTEM.lock();
298        self.width_opt = Some(width);
299        self.height_opt = Some(height);
300        self.buffer
301            .set_size(&mut font_system, Some(width), Some(height));
302    }
303
304    pub fn metrics(&self) -> Metrics {
305        self.buffer.metrics()
306    }
307
308    pub fn lines(&self) -> &[BufferLine] {
309        &self.buffer.lines
310    }
311
312    pub fn lines_range(&self) -> &[Range<usize>] {
313        &self.lines_range
314    }
315
316    pub fn layout_runs(&self) -> LayoutRunIter<'_> {
317        LayoutRunIter::new(self)
318    }
319
320    pub fn layout_cursor(&mut self, cursor: Cursor) -> LayoutCursor {
321        let line = cursor.line;
322        let mut font_system = FONT_SYSTEM.lock();
323        self.buffer
324            .layout_cursor(&mut font_system, cursor)
325            .unwrap_or_else(|| LayoutCursor::new(line, 0, 0))
326    }
327
328    pub fn hit_position(&self, idx: usize) -> HitPosition {
329        self.hit_position_aff(idx, Affinity::Before)
330    }
331
332    pub fn hit_position_aff(&self, idx: usize, affinity: Affinity) -> HitPosition {
333        let mut last_line = 0;
334        let mut last_end: usize = 0;
335        let mut offset = 0;
336        let mut glyph_tail_found = false;
337        let mut last_glyph: Option<&LayoutGlyph> = None;
338        let mut last_position = HitPosition {
339            line: 0,
340            point: Point::ZERO,
341            glyph_ascent: 0.0,
342            glyph_descent: 0.0,
343        };
344        for (line, run) in self.layout_runs().enumerate() {
345            if run.line_i > last_line {
346                last_line = run.line_i;
347                offset = last_end + 1;
348            }
349
350            // Handles wrapped lines, like:
351            // ```rust
352            // let config_path = |
353            // dirs::config_dir();
354            // ```
355            // The glyphs won't contain the space at the end of the first part, and the position right
356            // after the space is the same column as at `|dirs`, which is what before is letting us
357            // distinguish.
358            // So essentially, if the next run has a glyph that is at the same idx as the end of the
359            // previous run, *and* it is at `idx` itself, then we know to position it on the previous.
360            if let Some(last_glyph) = last_glyph {
361                if let Some(first_glyph) = run.glyphs.first() {
362                    let in_wrapped_tail = match affinity {
363                        // we resolve to the line before the next glyph
364                        Affinity::Before => first_glyph.start + offset == idx,
365                        // found the tail and it resolves within whitespace from the previous line
366                        Affinity::After => first_glyph.start + offset != idx && glyph_tail_found,
367                    };
368
369                    if in_wrapped_tail {
370                        last_position.point.x += last_glyph.w as f64;
371
372                        if last_end != idx {
373                            // if the last index doesn't match the start index,
374                            // it's due to whitespace
375                            last_position.point.x += last_glyph.w as f64;
376                        }
377
378                        last_position.point.y = 0.0;
379                        return last_position;
380                    }
381                }
382            }
383
384            for glyph in run.glyphs {
385                let glyph_start = glyph.start + offset;
386                let glyph_end = glyph.end + offset;
387
388                last_end = glyph_end;
389                last_glyph = Some(glyph);
390                last_position = HitPosition {
391                    line,
392                    point: Point::new(glyph.x as f64, run.line_y as f64),
393                    glyph_ascent: run.max_ascent as f64,
394                    glyph_descent: run.max_descent as f64,
395                };
396
397                glyph_tail_found = idx == glyph_end;
398
399                if (glyph_start..glyph_end).contains(&idx)
400                    || (affinity == Affinity::Before && glyph_tail_found)
401                {
402                    // possibly inside ligature, need to resolve glyph internal offset
403
404                    let glyph_str = &run.text[glyph.start..glyph.end];
405                    let relative_idx = idx - glyph_start;
406                    let mut total_graphemes = 0;
407                    let mut grapheme_i = 0;
408
409                    for (i, _) in glyph_str.grapheme_indices(true) {
410                        if relative_idx > i {
411                            grapheme_i += 1;
412                        }
413
414                        total_graphemes += 1;
415                    }
416
417                    if glyph.level.is_rtl() {
418                        grapheme_i = total_graphemes - grapheme_i;
419                    }
420
421                    last_position.point.x +=
422                        (grapheme_i as f64 / total_graphemes as f64) * glyph.w as f64;
423
424                    return last_position;
425                }
426            }
427        }
428
429        if let Some(last_glyph) = last_glyph {
430            last_position.point.x += last_glyph.w as f64;
431            return last_position;
432        }
433
434        HitPosition {
435            line: 0,
436            point: Point::ZERO,
437            glyph_ascent: 0.0,
438            glyph_descent: 0.0,
439        }
440    }
441
442    pub fn hit_point(&self, point: Point) -> HitPoint {
443        if let Some(cursor) = self.hit(point.x as f32, point.y as f32) {
444            let size = self.size();
445            let is_inside = point.x <= size.width && point.y <= size.height;
446            HitPoint {
447                line: cursor.line,
448                index: cursor.index,
449                is_inside,
450                affinity: cursor.affinity,
451            }
452        } else {
453            HitPoint {
454                line: 0,
455                index: 0,
456                is_inside: false,
457                affinity: Affinity::Before,
458            }
459        }
460    }
461
462    /// Convert x, y position to Cursor (hit detection)
463    pub fn hit(&self, x: f32, y: f32) -> Option<Cursor> {
464        self.buffer.hit(x, y)
465    }
466
467    pub fn line_col_position(&self, line: usize, col: usize) -> HitPosition {
468        let mut last_glyph: Option<&LayoutGlyph> = None;
469        let mut last_line = 0;
470        let mut last_line_y = 0.0;
471        let mut last_glyph_ascent = 0.0;
472        let mut last_glyph_descent = 0.0;
473        for (current_line, run) in self.layout_runs().enumerate() {
474            for glyph in run.glyphs {
475                match run.line_i.cmp(&line) {
476                    std::cmp::Ordering::Equal => {
477                        if glyph.start > col {
478                            return HitPosition {
479                                line: last_line,
480                                point: Point::new(
481                                    last_glyph.map(|g| (g.x + g.w) as f64).unwrap_or(0.0),
482                                    last_line_y as f64,
483                                ),
484                                glyph_ascent: last_glyph_ascent as f64,
485                                glyph_descent: last_glyph_descent as f64,
486                            };
487                        }
488                        if (glyph.start..glyph.end).contains(&col) {
489                            return HitPosition {
490                                line: current_line,
491                                point: Point::new(glyph.x as f64, run.line_y as f64),
492                                glyph_ascent: run.max_ascent as f64,
493                                glyph_descent: run.max_descent as f64,
494                            };
495                        }
496                    }
497                    std::cmp::Ordering::Greater => {
498                        return HitPosition {
499                            line: last_line,
500                            point: Point::new(
501                                last_glyph.map(|g| (g.x + g.w) as f64).unwrap_or(0.0),
502                                last_line_y as f64,
503                            ),
504                            glyph_ascent: last_glyph_ascent as f64,
505                            glyph_descent: last_glyph_descent as f64,
506                        };
507                    }
508                    std::cmp::Ordering::Less => {}
509                };
510                last_glyph = Some(glyph);
511            }
512            last_line = current_line;
513            last_line_y = run.line_y;
514            last_glyph_ascent = run.max_ascent;
515            last_glyph_descent = run.max_descent;
516        }
517
518        HitPosition {
519            line: last_line,
520            point: Point::new(
521                last_glyph.map(|g| (g.x + g.w) as f64).unwrap_or(0.0),
522                last_line_y as f64,
523            ),
524            glyph_ascent: last_glyph_ascent as f64,
525            glyph_descent: last_glyph_descent as f64,
526        }
527    }
528
529    pub fn size(&self) -> Size {
530        self.buffer
531            .layout_runs()
532            .fold(Size::new(0.0, 0.0), |mut size, run| {
533                let new_width = run.line_w as f64;
534                if new_width > size.width {
535                    size.width = new_width;
536                }
537
538                size.height += run.line_height as f64;
539
540                size
541            })
542    }
543}