Skip to main content

agg_gui/widgets/text_area/
band.rs

1//! Over-scan band planning for [`TextArea`]'s scroll backbuffer.
2//!
3//! Scrolling a `TextArea` used to change its backbuffer-cache signature (the raw
4//! scroll offset was part of the sig), so every wheel notch re-rastered the whole
5//! LCD backbuffer — ~120 ms at 1400×1000. This module implements the *over-scan
6//! band* model that decouples scrolling from rastering: the widget rasters a band
7//! covering the viewport plus a margin of content above and below, anchored in
8//! content space. As long as the live scroll offset stays inside the band,
9//! scrolling only changes the blit offset the framework applies to the cached
10//! band (see [`crate::widget::BackbufferBand`]) — no re-raster. Leaving the band
11//! re-anchors it around the new offset and rasters once.
12//!
13//! The functions here are the pure band math (kept side-effect free so they are
14//! unit-testable in isolation); the `impl TextArea` block wires them into the
15//! widget's layout / paint / `backbuffer_band` path.
16
17use super::*;
18use crate::widget::BackbufferBand;
19
20/// Find the visual-line index range that differs between two wraps, so an
21/// in-place edit can re-raster just that strip. Returns `(first, last,
22/// count_changed)` in NEW-line coordinates, where `first..=last` is the
23/// smallest range covering every changed line (a common prefix and suffix of
24/// identically-rendered lines is trimmed by comparing the rendered `text`).
25/// `count_changed` is true when the edit added or removed visual lines, in
26/// which case every line from `first` down shifts position and the caller must
27/// repaint through the band bottom. `None` when the two wraps render identically.
28pub(super) fn diff_line_range(
29    old: &[WrappedLine],
30    new: &[WrappedLine],
31) -> Option<(usize, usize, bool)> {
32    let count_changed = old.len() != new.len();
33    let min_len = old.len().min(new.len());
34    let mut first = 0;
35    while first < min_len && old[first].text == new[first].text {
36        first += 1;
37    }
38    if !count_changed && first == new.len() {
39        return None; // identical
40    }
41    let mut back = 0;
42    while back < min_len - first && old[old.len() - 1 - back].text == new[new.len() - 1 - back].text
43    {
44        back += 1;
45    }
46    let last = new.len().saturating_sub(1).saturating_sub(back);
47    Some((first, last.max(first), count_changed))
48}
49
50/// Over-scan margin as a fraction of the viewport height, applied to *each*
51/// side of the band. `0.5` gives one whole viewport of over-scan in total (half
52/// above, half below), so — for the code editor's ~17.5 px line pitch and a
53/// ~1000 px viewport — roughly nine 3-line wheel notches fit before the band
54/// must re-anchor. Larger values re-anchor less often but make each re-anchor
55/// raster (and the buffer) proportionally bigger; one viewport total is a good
56/// balance and matches the design brief.
57pub(crate) const BAND_OVERSCAN_FRACTION: f64 = 0.5;
58
59/// Anchor + over-scan extents of the currently rastered band. Recomputed each
60/// `layout`. `active` is false when the content fits (nothing to scroll), which
61/// keeps the widget on the byte-identical bounds-sized backbuffer path.
62#[derive(Clone, Copy, Default, PartialEq)]
63pub(crate) struct BandState {
64    pub active: bool,
65    /// Scroll offset (content space) the band was rastered at.
66    pub anchor: f64,
67    /// Extra logical px of content rastered above the viewport top.
68    pub over_top: f64,
69    /// Extra logical px of content rastered below the viewport bottom.
70    pub over_bottom: f64,
71}
72
73/// Over-scan extents around `anchor`, clamped to the content actually available
74/// on each side. `margin` is the desired extent per side (logical px).
75///
76/// Scrolling up consumes `over_top` (content above the viewport, of which there
77/// is `anchor` px); scrolling down consumes `over_bottom` (content below, of
78/// which there is `max_scroll - anchor` px). Clamping to the available content
79/// keeps the band from wasting buffer on regions that can never be scrolled into
80/// view, and guarantees the in-band range never extends past the scroll limits.
81pub(crate) fn plan_overscan(anchor: f64, max_scroll: f64, margin: f64) -> (f64, f64) {
82    let margin = margin.max(0.0);
83    let over_top = margin.min(anchor.max(0.0));
84    let over_bottom = margin.min((max_scroll - anchor).max(0.0));
85    (over_top, over_bottom)
86}
87
88/// Is the live scroll `offset` still inside the band anchored at `anchor`?
89///
90/// The band covers `[anchor - over_top, anchor + over_bottom]`; a small epsilon
91/// absorbs float wobble at the exact edge so we don't re-anchor spuriously.
92pub(crate) fn offset_in_band(offset: f64, anchor: f64, over_top: f64, over_bottom: f64) -> bool {
93    const EPS: f64 = 1e-6;
94    offset >= anchor - over_top - EPS && offset <= anchor + over_bottom + EPS
95}
96
97impl TextArea {
98    /// Recompute the over-scan band after the wrap cache + scroll offset are up
99    /// to date (called from `layout`, after `sync_scroll`). Re-anchors — and
100    /// thereby changes the cache signature, forcing one re-raster — only when
101    /// the live offset has left the current band, the band is inactive, or the
102    /// content shrank below the anchor. Ordinary in-band scrolling leaves the
103    /// band untouched, so the sig is stable and no re-raster happens.
104    pub(crate) fn recompute_band(&mut self) {
105        let max_scroll = self.max_scroll_y();
106        if max_scroll <= 0.0 || self.cached_line_h <= 0.0 {
107            // Everything fits: no scrolling, so keep the plain bounds-sized path.
108            self.band.active = false;
109            return;
110        }
111        let vp = self.inner_height();
112        let margin = vp * BAND_OVERSCAN_FRACTION;
113        let live = self.vbar.offset;
114        let in_band = self.band.active
115            && self.band.anchor <= max_scroll
116            && offset_in_band(
117                live,
118                self.band.anchor,
119                self.band.over_top,
120                self.band.over_bottom,
121            );
122        if !in_band {
123            let anchor = live.clamp(0.0, max_scroll);
124            let (over_top, over_bottom) = plan_overscan(anchor, max_scroll, margin);
125            self.band = BandState {
126                active: true,
127                anchor,
128                over_top,
129                over_bottom,
130            };
131        }
132    }
133
134    /// The [`BackbufferBand`] the framework reads to size the taller buffer and
135    /// compute the blit offset. `None` when the content fits (byte-identical
136    /// bounds-sized path). `blit_dy` is the residual between the live offset and
137    /// the band anchor — the only thing that changes as the user scrolls within
138    /// the band, and it flows purely into the blit, never the raster.
139    pub(crate) fn backbuffer_band_impl(&self) -> Option<BackbufferBand> {
140        if !self.band.active {
141            return None;
142        }
143        Some(BackbufferBand {
144            overscan_top: self.band.over_top,
145            overscan_bottom: self.band.over_bottom,
146            blit_dy: self.vbar.offset - self.band.anchor,
147            // The strip extent the framework will confine the plane update to is
148            // exactly the extent `paint` fills + clips for its strip repaint (see
149            // `dirty_strip_y`), so both sides agree on the affected rows. `None`
150            // when no strip is planned → a granted partial repaints the whole band.
151            dirty_strip_y: self
152                .render_dirty_lines
153                .get()
154                .map(|(first, last)| self.dirty_strip_y(first, last)),
155        })
156    }
157
158    /// Vertical extent (local Y-up) of the band being painted into the buffer:
159    /// the padded inner rect expanded by the over-scan margins. Used to expand
160    /// the paint clip and to cull lines that fall entirely outside the band.
161    /// Collapses to the plain inner rect when the band is inactive.
162    pub(crate) fn band_clip_y(&self) -> (f64, f64) {
163        let lo = self.padding;
164        let hi = (self.bounds.height - self.padding).max(lo);
165        if self.band.active {
166            (lo - self.band.over_bottom, hi + self.band.over_top)
167        } else {
168            (lo, hi)
169        }
170    }
171
172    /// Test/introspection hook: number of real backbuffer re-rasters (each
173    /// `paint` into the buffer bumps it). Scrolling within a band must not
174    /// increment this; re-anchoring or editing must.
175    pub fn debug_raster_count(&self) -> u64 {
176        self.raster_count.get()
177    }
178
179    /// Test/introspection hook: number of *partial* (dirty-line-strip)
180    /// re-rasters. A localized edit within an active band bumps this; a
181    /// re-anchor or a structural change (resize, wrap re-flow) does not.
182    pub fn debug_strip_raster_count(&self) -> u64 {
183        self.strip_raster_count.get()
184    }
185
186    /// Decide whether the pending re-raster can be confined to the edited line
187    /// strip (repainting just those rows into the retained band buffer) instead
188    /// of rebuilding the whole band. Sets [`Self::render_dirty_lines`]; `None`
189    /// there means a full band repaint.
190    ///
191    /// A strip is safe only when the edit is a plain in-place text change with a
192    /// collapsed caret (no selection band to update elsewhere) and nothing
193    /// structural moved — same size, same band anchor + over-scan, same
194    /// alignment and font size as the last raster. Anything else (a re-anchor, a
195    /// resize, a selection, a width re-flow) falls back to a full repaint, which
196    /// is always correct because the widget paints opaque coverage over the
197    /// whole band. The framework additionally vetoes the strip (via
198    /// `partial_allowed`) whenever it could not reuse the retained buffer.
199    pub(crate) fn plan_dirty_strip(&mut self) {
200        self.render_dirty_lines.set(None);
201        if !self.band.active {
202            return;
203        }
204        let Some((first, last, count_changed)) = self.wrap_change else {
205            return;
206        };
207        let Some(prev) = self.last_sig.as_ref() else {
208            return;
209        };
210        let (now_no_sel, _) = {
211            let st = self.edit.borrow();
212            (st.cursor == st.anchor, ())
213        };
214        let stable = now_no_sel
215            && prev.cursor == prev.anchor
216            && prev.band_active
217            && prev.band_anchor_bits == self.band.anchor.to_bits()
218            && prev.band_over_top_bits == self.band.over_top.to_bits()
219            && prev.band_over_bottom_bits == self.band.over_bottom.to_bits()
220            && prev.w_bits == self.bounds.width.to_bits()
221            && prev.h_bits == self.bounds.height.to_bits()
222            && prev.h_align == self.resolved_h_align()
223            && prev.v_align == self.resolved_v_align()
224            && prev.font_size_bits == self.font_size.to_bits();
225        if !stable || self.cached_lines.is_empty() {
226            return;
227        }
228        let n = self.cached_lines.len();
229        // A wrap-count change shifts every line from `first` down, so repaint
230        // through the last line (the band clip + per-line culling keep the
231        // actual work to the band-visible rows).
232        let end = if count_changed {
233            n - 1
234        } else {
235            last.min(n - 1)
236        };
237        let first = first.min(n - 1);
238        self.render_dirty_lines.set(Some((first, end.max(first))));
239    }
240
241    /// Y-up strip extent (clamped to the band clip) covering visual lines
242    /// `first..=last`, used by `paint` to fill the strip background and clip the
243    /// strip re-raster. Uses the same `line_top_y` the glyph loop uses, so the
244    /// strip lands exactly on the rows those lines paint into.
245    pub(crate) fn dirty_strip_y(&self, first: usize, last: usize) -> (f64, f64) {
246        let (clip_lo, clip_hi) = self.band_clip_y();
247        // Line cells stack top-to-bottom in Y-up: `first` (smaller index) is
248        // higher (larger Y). Use exact cell boundaries — the 1.35× line pitch
249        // leaves a gap between glyph runs, so an opaque strip fill at the cell
250        // edges covers the changed lines' glyphs without erasing the un-
251        // repainted neighbours above/below (which the retained buffer keeps).
252        let hi = self.line_top_y(first).min(clip_hi);
253        let lo = (self.line_top_y(last) - self.cached_line_h).max(clip_lo);
254        (lo, hi.max(lo))
255    }
256
257    /// Stroke the rounded border in the current focus/hover colour. Shared by
258    /// the cached (non-band) paint and the fixed band-mode overlay so the frame
259    /// looks identical either way.
260    pub(crate) fn paint_border(&self, ctx: &mut dyn DrawCtx, v: &crate::theme::Visuals) {
261        let w = self.bounds.width;
262        let h = self.bounds.height;
263        let border = if self.focused {
264            v.accent
265        } else if self.hovered {
266            v.widget_stroke_active
267        } else {
268            v.widget_stroke
269        };
270        let line_width = if self.focused { 2.0 } else { 1.0 };
271        ctx.set_stroke_color(border);
272        ctx.set_line_width(line_width);
273        ctx.begin_path();
274        let inset = line_width * 0.5;
275        ctx.rounded_rect(
276            inset,
277            inset,
278            (w - line_width).max(0.0),
279            (h - line_width).max(0.0),
280            4.0,
281        );
282        ctx.stroke();
283    }
284
285    /// Re-establish the fixed frame over a scrolled band blit: fill the padding
286    /// ring (rounded outer edge minus the sharp inner content rect, even-odd) in
287    /// the widget bg so text that bled into the padding while scrolling is
288    /// covered and the rounded corners are restored, then stroke the border.
289    /// Drawn in `paint_overlay` so it never scrolls with the cached content.
290    pub(crate) fn paint_band_frame(&self, ctx: &mut dyn DrawCtx, v: &crate::theme::Visuals) {
291        let w = self.bounds.width;
292        let h = self.bounds.height;
293        let inner_w = (w - self.padding * 2.0).max(0.0);
294        let inner_h = (h - self.padding * 2.0).max(0.0);
295        ctx.set_fill_color(v.widget_bg);
296        ctx.set_fill_rule(crate::draw_ctx::FillRule::EvenOdd);
297        ctx.begin_path();
298        ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
299        ctx.rect(self.padding, self.padding, inner_w, inner_h);
300        ctx.fill();
301        ctx.set_fill_rule(crate::draw_ctx::FillRule::NonZero);
302        self.paint_border(ctx, v);
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::{offset_in_band, plan_overscan};
309
310    #[test]
311    fn overscan_is_symmetric_in_the_interior() {
312        // Anchored mid-document with plenty of content both sides: full margin.
313        let (top, bottom) = plan_overscan(1000.0, 2000.0, 400.0);
314        assert_eq!(top, 400.0);
315        assert_eq!(bottom, 400.0);
316    }
317
318    #[test]
319    fn overscan_clamps_to_available_content_at_the_top() {
320        // Near the top there are only 100 px of content above the viewport, so
321        // the top over-scan can't exceed that (nothing to raster above it).
322        let (top, bottom) = plan_overscan(100.0, 2000.0, 400.0);
323        assert_eq!(top, 100.0);
324        assert_eq!(bottom, 400.0);
325    }
326
327    #[test]
328    fn overscan_clamps_to_available_content_at_the_bottom() {
329        let (top, bottom) = plan_overscan(1950.0, 2000.0, 400.0);
330        assert_eq!(top, 400.0);
331        assert_eq!(bottom, 50.0);
332    }
333
334    #[test]
335    fn in_band_covers_anchor_plus_minus_overscan() {
336        let (anchor, over_top, over_bottom) = (1000.0, 400.0, 400.0);
337        assert!(offset_in_band(1000.0, anchor, over_top, over_bottom));
338        assert!(offset_in_band(600.0, anchor, over_top, over_bottom)); // top edge
339        assert!(offset_in_band(1400.0, anchor, over_top, over_bottom)); // bottom edge
340        assert!(!offset_in_band(599.0, anchor, over_top, over_bottom));
341        assert!(!offset_in_band(1401.0, anchor, over_top, over_bottom));
342    }
343
344    #[test]
345    fn planned_band_never_extends_past_scroll_limits() {
346        // Whatever the anchor, anchor - over_top >= 0 and
347        // anchor + over_bottom <= max_scroll, so the in-band range is always a
348        // reachable sub-range of [0, max_scroll].
349        let max_scroll = 2000.0;
350        let margin = 700.0;
351        for &anchor in &[0.0, 5.0, 350.0, 1000.0, 1990.0, 2000.0] {
352            let (top, bottom) = plan_overscan(anchor, max_scroll, margin);
353            assert!(anchor - top >= -1e-9, "top edge below 0 at anchor {anchor}");
354            assert!(
355                anchor + bottom <= max_scroll + 1e-9,
356                "bottom edge past max at anchor {anchor}"
357            );
358        }
359    }
360}