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}