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