ailloli_ui_editor/layout/
visible_lines.rs

1//! Visible paragraph shaping with filtered and no-wrap virtualized paths.
2
3use ailloli_ui_core::{Size, TextStyle};
4use ailloli_ui_text::{TextBuffer, TextSystem};
5
6use crate::layout::layout_cache::LayoutCacheKey;
7use crate::layout::{
8    first_layout_baseline, layout_visual_height, EditorTextRun, FenwickTree, LayoutCache,
9    ParagraphMetrics, ParagraphMetricsCache, ParagraphMetricsStats,
10};
11use crate::{EditorStyle, EditorViewport, EditorWrapMode};
12
13/// Visible paragraph layouts plus whole-content scroll metrics.
14///
15/// # Examples
16///
17/// ```
18/// use ailloli_ui_core::Size;
19/// use ailloli_ui_editor::layout::{ParagraphMetricsStats, VisibleTextLayout};
20/// let visible = VisibleTextLayout {
21///     runs: Vec::new(),
22///     content_size: Size::new(0.0, 0.0),
23///     metrics_stats: ParagraphMetricsStats::default(),
24///     used_fast_path: false,
25/// };
26/// assert!(visible.runs.is_empty());
27/// ```
28#[derive(Debug, Clone)]
29pub struct VisibleTextLayout {
30    /// Paragraph runs intersecting the viewport, in logical order.
31    pub runs: Vec<EditorTextRun>,
32    /// Estimated or shaped full-content width and height in logical pixels.
33    pub content_size: Size,
34    /// Metrics-cache activity during this build.
35    pub metrics_stats: ParagraphMetricsStats,
36    /// Whether the no-wrap virtualized path avoided shaping offscreen lines.
37    pub used_fast_path: bool,
38}
39
40/// Builds visible paragraph runs for the current viewport.
41///
42/// This is equivalent to [`build_visible_text_layout_filtered`] with no hidden
43/// paragraphs. No-wrap mode uses a virtualized shaping path; soft-wrap mode must
44/// shape every paragraph to determine wrapped heights. Paragraph trailing `\n`
45/// bytes are excluded from run layouts and source ranges.
46///
47/// # Examples
48///
49/// ```
50/// use ailloli_ui_core::Rect;
51/// use ailloli_ui_editor::{layout::{build_visible_text_layout, LayoutCache, ParagraphMetricsCache}, EditorConfig, EditorStyle, EditorViewport};
52/// use ailloli_ui_text::{TextBuffer, TextEditState, TextSystem};
53/// let buffer = TextBuffer::from_string("one\ntwo");
54/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new());
55/// let mut layouts = LayoutCache::default();
56/// let mut metrics = ParagraphMetricsCache::default();
57/// let visible = build_visible_text_layout(&buffer, viewport, EditorStyle::default(), &mut layouts, &mut metrics, &mut TextSystem::new());
58/// assert_eq!(visible.runs.iter().map(|run| run.index).collect::<Vec<_>>(), [0, 1]);
59/// assert!(!visible.used_fast_path);
60/// ```
61pub fn build_visible_text_layout(
62    buffer: &TextBuffer,
63    viewport: EditorViewport,
64    style: EditorStyle,
65    layout_cache: &mut LayoutCache,
66    metrics_cache: &mut ParagraphMetricsCache,
67    text_system: &mut TextSystem,
68) -> VisibleTextLayout {
69    build_visible_text_layout_filtered(
70        buffer,
71        viewport,
72        style,
73        layout_cache,
74        metrics_cache,
75        text_system,
76        &|_| false,
77    )
78}
79
80/// Builds visible runs while completely removing selected logical paragraphs.
81///
82/// `is_hidden` receives every paragraph index during fast-path eligibility and
83/// later layout. Hidden paragraphs consume no vertical space and produce no
84/// run or metrics entry. The returned content size therefore describes the
85/// filtered document. A filtering closure that hides nothing permits the
86/// no-wrap virtualized path.
87///
88/// # Examples
89///
90/// ```
91/// use ailloli_ui_core::Rect;
92/// use ailloli_ui_editor::{layout::{build_visible_text_layout_filtered, LayoutCache, ParagraphMetricsCache}, EditorConfig, EditorStyle, EditorViewport};
93/// use ailloli_ui_text::{TextBuffer, TextEditState, TextSystem};
94/// let buffer = TextBuffer::from_string("zero\none\ntwo");
95/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 120.0, 80.0), EditorConfig::default(), &TextEditState::new());
96/// let mut layouts = LayoutCache::default();
97/// let mut metrics = ParagraphMetricsCache::default();
98/// let visible = build_visible_text_layout_filtered(&buffer, viewport, EditorStyle::default(), &mut layouts, &mut metrics, &mut TextSystem::new(), &|index| index == 1);
99/// assert_eq!(visible.runs.iter().map(|run| run.index).collect::<Vec<_>>(), [0, 2]);
100/// ```
101pub fn build_visible_text_layout_filtered(
102    buffer: &TextBuffer,
103    viewport: EditorViewport,
104    style: EditorStyle,
105    layout_cache: &mut LayoutCache,
106    metrics_cache: &mut ParagraphMetricsCache,
107    text_system: &mut TextSystem,
108    is_hidden: &dyn Fn(usize) -> bool,
109) -> VisibleTextLayout {
110    if viewport.wrap_mode == EditorWrapMode::NoWrap
111        && !(0..buffer.paragraphs().len()).any(is_hidden)
112    {
113        return build_visible_nowrap_text_layout(
114            buffer,
115            viewport,
116            style,
117            layout_cache,
118            metrics_cache,
119            text_system,
120        );
121    }
122
123    let viewport_top = viewport.scroll_y;
124    let viewport_bot = viewport_top + viewport.content_rect.h.max(0.0);
125    let text_style = TextStyle::new(style.font, style.px_size, style.fg);
126    let mut runs = Vec::new();
127    let mut para_top = 0.0;
128    let mut content_width = 0.0f32;
129    let mut stats = ParagraphMetricsStats::default();
130    let wrap_mode = viewport.text_wrap_mode();
131    let max_width = viewport.max_text_width();
132
133    for (p_idx, meta) in buffer.paragraphs().iter().enumerate() {
134        if is_hidden(p_idx) {
135            continue;
136        }
137        let Some(paragraph) = buffer.paragraph_text(p_idx) else {
138            continue;
139        };
140        let trimmed = paragraph.trim_end_matches('\n').to_string();
141        let key = LayoutCacheKey::new(
142            p_idx,
143            meta.revision,
144            &trimmed,
145            text_style,
146            wrap_mode,
147            max_width,
148        );
149        let layout = layout_cache.layout_paragraph(
150            key,
151            &trimmed,
152            text_style,
153            max_width,
154            wrap_mode,
155            text_system,
156        );
157        let metrics = if let Some(metrics) = metrics_cache.get(&key) {
158            stats.hits += 1;
159            metrics
160        } else {
161            stats.misses += 1;
162            let metrics = ParagraphMetrics {
163                width: layout.width(),
164                height: layout_visual_height(&layout, style),
165                line_count: layout.lines.len().max(1),
166                local_version: meta.revision,
167            };
168            metrics_cache.insert(key, metrics);
169            metrics
170        };
171        content_width = content_width.max(metrics.width);
172        let para_bottom = para_top + metrics.height;
173        if para_bottom >= viewport_top && para_top <= viewport_bot {
174            runs.push(EditorTextRun {
175                index: p_idx,
176                byte_range: meta.byte_range.start..(meta.byte_range.start + trimmed.len()),
177                baseline_y: para_top - viewport_top + first_layout_baseline(&layout),
178                layout,
179            });
180        }
181        para_top = para_bottom;
182    }
183
184    metrics_cache.update_metadata(
185        buffer.paragraphs().len(),
186        buffer.paragraphs().iter().map(|meta| meta.revision),
187        para_top,
188        content_width,
189    );
190
191    VisibleTextLayout {
192        runs,
193        content_size: Size::new(content_width, para_top),
194        metrics_stats: stats,
195        used_fast_path: false,
196    }
197}
198
199/// Builds a no-wrap viewport while shaping only visible and overscan paragraphs.
200///
201/// The full document is still scanned to estimate its widest line. The estimate
202/// counts Unicode scalar values at `0.62 * px_size`; shaped visible widths can
203/// increase it. Vertical height uses the configured line-height floor.
204fn build_visible_nowrap_text_layout(
205    buffer: &TextBuffer,
206    viewport: EditorViewport,
207    style: EditorStyle,
208    layout_cache: &mut LayoutCache,
209    metrics_cache: &mut ParagraphMetricsCache,
210    text_system: &mut TextSystem,
211) -> VisibleTextLayout {
212    let text_style = TextStyle::new(style.font, style.px_size, style.fg);
213    let line_height = style.line_height.max(1.0);
214    let paragraph_count = buffer.paragraphs().len();
215    let viewport_top = viewport.scroll_y.max(0.0);
216    let viewport_bot = viewport_top + viewport.content_rect.h.max(0.0);
217    let heights = vec![line_height; paragraph_count];
218    let height_index = FenwickTree::from_values(&heights);
219    let first = height_index.lower_bound(viewport_top).min(paragraph_count);
220    let last = (height_index.lower_bound(viewport_bot) + 2).min(paragraph_count);
221    let mut runs = Vec::new();
222    let max_width = viewport.max_text_width();
223    let wrap_mode = viewport.text_wrap_mode();
224    let mut stats = ParagraphMetricsStats::default();
225
226    // In NoWrap mode horizontal scroll metrics must account for the whole
227    // document, including the longest line when it sits outside the viewport,
228    // without shaping every paragraph and losing the large-file fast path.
229    let mut content_width = 0.0f32;
230    for p_idx in 0..paragraph_count {
231        if let Some(paragraph) = buffer.paragraph_text(p_idx) {
232            content_width = content_width.max(estimate_nowrap_width(
233                paragraph.trim_end_matches('\n'),
234                style,
235            ));
236        }
237    }
238
239    for p_idx in first.min(paragraph_count)..last {
240        let meta = &buffer.paragraphs()[p_idx];
241        let Some(paragraph) = buffer.paragraph_text(p_idx) else {
242            continue;
243        };
244        let trimmed = paragraph.trim_end_matches('\n').to_string();
245        let key = LayoutCacheKey::new(
246            p_idx,
247            meta.revision,
248            &trimmed,
249            text_style,
250            wrap_mode,
251            max_width,
252        );
253        let layout = layout_cache.layout_paragraph(
254            key,
255            &trimmed,
256            text_style,
257            max_width,
258            wrap_mode,
259            text_system,
260        );
261        let metrics = if let Some(metrics) = metrics_cache.get(&key) {
262            stats.hits += 1;
263            metrics
264        } else {
265            stats.misses += 1;
266            let metrics = ParagraphMetrics {
267                width: layout.width(),
268                height: layout_visual_height(&layout, style),
269                line_count: 1,
270                local_version: meta.revision,
271            };
272            metrics_cache.insert(key, metrics);
273            metrics
274        };
275        content_width = content_width.max(metrics.width);
276        runs.push(EditorTextRun {
277            index: p_idx,
278            byte_range: meta.byte_range.start..(meta.byte_range.start + trimmed.len()),
279            baseline_y: p_idx as f32 * line_height - viewport_top + first_layout_baseline(&layout),
280            layout,
281        });
282    }
283    metrics_cache.update_metadata(
284        paragraph_count,
285        buffer.paragraphs().iter().map(|meta| meta.revision),
286        paragraph_count as f32 * line_height,
287        content_width,
288    );
289
290    VisibleTextLayout {
291        runs,
292        content_size: Size::new(content_width, paragraph_count as f32 * line_height),
293        metrics_stats: stats,
294        used_fast_path: true,
295    }
296}
297
298/// Estimates a no-wrap line width from Unicode scalar count and font size.
299fn estimate_nowrap_width(text: &str, style: EditorStyle) -> f32 {
300    text.chars().count() as f32 * style.px_size as f32 * 0.62
301}