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}