Skip to main content

floem/views/editor/
layout.rs

1use std::ops::Range;
2
3use crate::{
4    peniko::Color,
5    text::{Affinity, TextLayout, paragraph_ranges},
6};
7use floem_editor_core::buffer::rope_text::RopeText;
8
9use super::{phantom_text::PhantomTextLine, visual_line::TextLayoutProvider};
10
11#[derive(Clone, Debug)]
12pub struct LineExtraStyle {
13    pub x: f64,
14    pub y: f64,
15    pub width: Option<f64>,
16    pub height: f64,
17    pub bg_color: Option<Color>,
18    pub under_line: Option<Color>,
19    pub wave_line: Option<Color>,
20}
21
22#[derive(Clone)]
23pub struct TextLayoutLine {
24    /// Extra styling that should be applied to the text
25    /// (x0, x1 or line display end, style)
26    pub extra_style: Vec<LineExtraStyle>,
27    pub text: TextLayout,
28    pub whitespaces: Option<Vec<(char, (f64, f64))>>,
29    pub indent: f64,
30    pub phantom_text: PhantomTextLine,
31}
32
33/// Check if a text range within `full_text` contains non-whitespace characters.
34/// Returns false for out-of-bounds or empty ranges.
35fn has_visible_content(full_text: &str, range: &Range<usize>) -> bool {
36    if range.end > full_text.len() || range.start >= range.end {
37        return false;
38    }
39    full_text.as_bytes()[range.start..range.end]
40        .iter()
41        .any(|&b| !b.is_ascii_whitespace())
42}
43
44impl TextLayoutLine {
45    /// The number of line breaks in the text layout. Always at least `1`.
46    /// Only counts non-empty visual lines (matching old relevant_layouts behavior).
47    pub fn line_count(&self) -> usize {
48        self.relevant_layout_count().max(1)
49    }
50
51    /// Count of visual lines that contain non-whitespace content.
52    /// Parley's whitespace-only lines are always trailing, so we scan
53    /// backwards to find the last visible line rather than filtering all lines.
54    pub fn relevant_layout_count(&self) -> usize {
55        let count = self.text.visual_line_count();
56        let full_text = self.text.text();
57        (0..count)
58            .rev()
59            .find(|&i| {
60                self.text
61                    .visual_line_text_range(i)
62                    .is_some_and(|r| has_visible_content(full_text, &r))
63            })
64            .map_or(0, |last| last + 1)
65    }
66
67    /// Iterator over the (start, end) columns of the relevant layouts.
68    pub fn layout_cols<'a>(
69        &'a self,
70        text_prov: impl TextLayoutProvider + 'a,
71        line: usize,
72    ) -> impl Iterator<Item = (usize, usize)> + 'a {
73        let visual_line_count = self.text.visual_line_count();
74        let full_text = self.text.text();
75        let line_v = line;
76
77        // Single pass: collect visible ranges and determine prefix.
78        let visual_ranges: Vec<_> = (0..visual_line_count)
79            .filter_map(|i| {
80                let range = self.text.visual_line_text_range(i)?;
81                has_visible_content(full_text, &range).then_some(range)
82            })
83            .collect();
84
85        let prefix = if visual_ranges.is_empty()
86            && paragraph_ranges(full_text).count() == 1
87            && visual_line_count > 0
88        {
89            let s = paragraph_ranges(full_text)
90                .next()
91                .map_or(0, |range| range.start);
92            Some((s, s))
93        } else {
94            None
95        };
96
97        let iter = visual_ranges.into_iter().map(move |text_range| {
98            let start_idx = text_range.start;
99            let mut end_idx = text_range.end.min(full_text.len());
100
101            // Strip trailing whitespace from byte range (matching old behavior).
102            while end_idx > start_idx {
103                let ch = full_text.as_bytes().get(end_idx - 1).copied().unwrap_or(0);
104                if ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r' {
105                    end_idx -= 1;
106                } else {
107                    break;
108                }
109            }
110
111            let start = start_idx;
112            let end = end_idx;
113
114            let text = text_prov.rope_text();
115            let pre_end = text_prov.before_phantom_col(line_v, end);
116            let line_offset = text.offset_of_line(line);
117            let line_end = text.line_end_col(line, true);
118
119            let end = if pre_end <= line_end {
120                let after = text.slice_to_cow(line_offset + pre_end..line_offset + line_end);
121                if after.starts_with(' ') && !after.starts_with("  ") {
122                    end + 1
123                } else {
124                    end
125                }
126            } else {
127                end
128            };
129
130            (start, end)
131        });
132
133        prefix.into_iter().chain(iter)
134    }
135
136    /// Iterator over only the start columns of the relevant layouts.
137    /// Cheaper than `layout_cols` — skips the end-column adjustment that
138    /// involves phantom-text resolution, rope lookups and slice comparisons.
139    pub fn start_layout_cols(&self) -> impl Iterator<Item = usize> + '_ {
140        let visual_line_count = self.text.visual_line_count();
141        let full_text = self.text.text();
142
143        let mut starts: Vec<usize> = (0..visual_line_count)
144            .filter_map(|i| {
145                let range = self.text.visual_line_text_range(i)?;
146                has_visible_content(full_text, &range).then_some(range.start)
147            })
148            .collect();
149
150        // Fallback for all-whitespace single paragraph.
151        if starts.is_empty() && paragraph_ranges(full_text).count() == 1 && visual_line_count > 0 {
152            starts.push(
153                paragraph_ranges(full_text)
154                    .next()
155                    .map_or(0, |range| range.start),
156            );
157        }
158
159        starts.into_iter()
160    }
161
162    /// Get the baseline y position of the given visual line index
163    pub fn get_layout_y(&self, nth: usize) -> Option<f32> {
164        self.text.visual_line_y(nth)
165    }
166
167    /// Get the (start x, end x) positions of the given visual line index
168    pub fn get_layout_x(&self, nth: usize) -> Option<(f32, f32)> {
169        let text_range = self.text.visual_line_text_range(nth)?;
170        let full_text = self.text.text();
171
172        if text_range.is_empty() || text_range.end > full_text.len() {
173            return Some((0.0, 0.0));
174        }
175
176        let start_x = self
177            .text
178            .cursor_point(text_range.start, Affinity::Upstream)
179            .x;
180        // For end, find last non-whitespace char
181        let mut end_byte = text_range.end;
182        while end_byte > text_range.start {
183            let ch = full_text.as_bytes().get(end_byte - 1).copied().unwrap_or(0);
184            if ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r' {
185                end_byte -= 1;
186            } else {
187                break;
188            }
189        }
190        let end_x = self.text.cursor_point(end_byte, Affinity::Upstream).x;
191
192        Some((start_x as f32, end_x as f32))
193    }
194}