Skip to main content

agg_gui/widgets/text_area/
scroll.rs

1//! Internal vertical scrolling for [`TextArea`], split out of `text_area.rs`
2//! to keep that file under the 800-line cap.
3//!
4//! Rather than embedding a `ScrollView`, `TextArea` composes the same
5//! [`ScrollbarAxis`](crate::widgets::scrollbar::ScrollbarAxis) primitive that
6//! `ScrollView` uses. That keeps the overflow math, thumb geometry, drag/hover
7//! interaction and painting pixel-consistent with every other scroll bar in the
8//! app, and lets the widget read the global scroll style/visibility so the
9//! Appearance demo's live tweaks apply here too.
10//!
11//! The scroll offset (`vbar.offset`) is folded into
12//! [`TextArea::content_top_y`], so all existing geometry (paint, hit-test,
13//! cursor overlay) inherits scrolling for free. `0` shows the first line;
14//! larger offsets reveal lower lines.
15//!
16//! The vertical bar is a *floating overlay* (it does not shrink the text wrap
17//! width). This matches the default [`ScrollBarKind::Floating`] and avoids a
18//! wrap/overflow feedback loop; the thin dormant bar rarely occludes glyphs and
19//! grows only while hovered/dragged.
20
21use super::*;
22use crate::geometry::Point;
23use crate::widgets::scroll_view::{
24    current_scroll_style, current_scroll_visibility, ScrollBarStyle, ScrollBarVisibility,
25};
26use crate::widgets::scrollbar::{
27    paint_prepared_scrollbar, ScrollbarGeometry, ScrollbarOrientation, DEFAULT_GRAB_MARGIN,
28};
29
30/// Outcome of routing a `MouseMove` through the scroll bar first.
31pub(crate) enum ScrollMove {
32    /// The bar thumb is being dragged; `bool` = offset changed this move.
33    Dragging(bool),
34    /// Not dragging; `bool` = the bar's hover state changed this move.
35    Hover(bool),
36}
37
38/// Shared read-out of a [`TextArea`]'s scroll state for a *sibling* widget that
39/// must mirror the editor's viewport (e.g. a line-number gutter) but lives
40/// outside its subtree and so cannot see the internal scroll bar. Published
41/// through [`TextArea::with_scroll_watch`].
42///
43/// The gutter's core problem is that the editor scrolls in *visual-row* pixel
44/// space while it numbers *source* lines; with soft-wrapping the two diverge, so
45/// a bare offset is not enough. `source_line_rows` bridges that: it maps each
46/// source line to the visual row its number should sit on.
47#[derive(Clone, Debug, Default)]
48pub struct TextAreaScrollInfo {
49    /// Vertical scroll offset in px from the top (0 = first row visible), the
50    /// same value [`TextArea::scroll_offset`] reports. Refreshed on every offset
51    /// change (wheel, drag, scroll-to-caret, relayout clamp).
52    pub offset_px: f64,
53    /// For each source line (paragraph), the index of its first wrapped visual
54    /// row. `source_line_rows[i]` is where source line `i`'s number belongs;
55    /// continuation rows of a wrapped line fall between consecutive entries and
56    /// get no number. With no soft-wrapping this degenerates to `rows[i] == i`.
57    /// Refreshed only when the wrap cache rebuilds (not per scroll).
58    pub source_line_rows: Vec<usize>,
59}
60
61impl TextArea {
62    /// Inner content height (widget height minus vertical padding).
63    pub(crate) fn inner_height(&self) -> f64 {
64        (self.bounds.height - self.padding * 2.0).max(0.0)
65    }
66
67    /// Total wrapped-content height at the current line count.
68    pub(crate) fn content_height(&self) -> f64 {
69        self.cached_lines.len() as f64 * self.cached_line_h
70    }
71
72    /// Furthest the content can scroll (0 when everything fits).
73    pub(crate) fn max_scroll_y(&self) -> f64 {
74        (self.content_height() - self.inner_height()).max(0.0)
75    }
76
77    /// Current vertical scroll offset (0 = top). Exposed for tests/inspectors.
78    pub fn scroll_offset(&self) -> f64 {
79        self.vbar.offset
80    }
81
82    /// Publish this widget's live scroll state into a shared
83    /// [`TextAreaScrollInfo`] whenever it changes, so a *sibling* widget outside
84    /// the editor's subtree can mirror the viewport — the Code Editor demo's
85    /// line-number gutter uses it to keep numbers aligned with the scrolled,
86    /// soft-wrapped text, which it otherwise cannot see. The offset is refreshed
87    /// on every scroll and the source-line-to-row map on every re-wrap, so both
88    /// are current before the next frame paints.
89    pub fn with_scroll_watch(mut self, watch: Rc<RefCell<TextAreaScrollInfo>>) -> Self {
90        self.scroll_watch = Some(watch);
91        self.publish_scroll_offset();
92        self.publish_scroll_rows();
93        self
94    }
95
96    /// Number of whole visual lines that fit in the viewport — the distance
97    /// PageUp/PageDown move the caret. Always at least 1 so the caret advances
98    /// even in a viewport shorter than one line.
99    pub(crate) fn page_lines(&self) -> usize {
100        if self.cached_line_h <= 0.0 {
101            return 1;
102        }
103        ((self.inner_height() / self.cached_line_h).floor() as usize).max(1)
104    }
105
106    fn scroll_style(&self) -> ScrollBarStyle {
107        current_scroll_style()
108    }
109    fn scroll_visibility(&self) -> ScrollBarVisibility {
110        current_scroll_visibility()
111    }
112
113    /// Mirror the current `vbar.offset` into the scroll-watch binding. Called
114    /// from every site that moves the offset so a sibling widget (the
115    /// line-number gutter) never paints a frame behind the text. See
116    /// [`TextArea::with_scroll_watch`].
117    fn publish_scroll_offset(&self) {
118        if let Some(watch) = &self.scroll_watch {
119            watch.borrow_mut().offset_px = self.vbar.offset;
120        }
121    }
122
123    /// Recompute the source-line-to-visual-row map from the wrap cache and
124    /// publish it. Called only on a real re-wrap (the `refresh_wrap` choke
125    /// point), since wrapping only changes when the text or width does — not on
126    /// scroll. Each source line (paragraph) spans the wrapped rows up to and
127    /// including the one marked `hard_break`, so the first row of the next
128    /// source line is one past each hard break; source line 0 always starts at
129    /// row 0.
130    pub(crate) fn publish_scroll_rows(&self) {
131        let Some(watch) = &self.scroll_watch else {
132            return;
133        };
134        let mut rows = Vec::with_capacity(self.cached_lines.len() + 1);
135        rows.push(0);
136        for (idx, line) in self.cached_lines.iter().enumerate() {
137            if line.hard_break {
138                rows.push(idx + 1);
139            }
140        }
141        watch.borrow_mut().source_line_rows = rows;
142    }
143
144    /// Refresh the axis with the current content height and re-clamp the offset.
145    /// Called each layout after the wrap cache is rebuilt.
146    pub(crate) fn sync_scroll(&mut self) {
147        self.vbar.enabled = true;
148        self.vbar.content = self.content_height();
149        self.vbar.clamp_offset(self.inner_height());
150        self.publish_scroll_offset();
151    }
152
153    /// Vertical track geometry in widget-local Y-up coords. The bar hugs the
154    /// right inner edge; the track spans the padded inner height.
155    fn v_scrollbar_geometry(&self) -> ScrollbarGeometry {
156        ScrollbarGeometry {
157            orientation: ScrollbarOrientation::Vertical,
158            track_start: self.padding,
159            track_end: (self.bounds.height - self.padding).max(self.padding),
160            cross_end: (self.bounds.width - 2.0).max(0.0),
161            hit_margin: DEFAULT_GRAB_MARGIN,
162        }
163    }
164
165    /// Scroll so the caret's visual line is fully visible. Re-wraps first (the
166    /// cache may be dirty after an edit) so the line index and content height
167    /// are current. Called after every cursor-affecting key.
168    pub(crate) fn ensure_cursor_visible(&mut self) {
169        let inner_w = self.inner_width();
170        self.refresh_wrap(inner_w);
171        let line_h = self.cached_line_h;
172        if line_h <= 0.0 {
173            return;
174        }
175        let inner_h = self.inner_height();
176        let cursor = self.edit.borrow().cursor;
177        let line = self.line_for_cursor(cursor);
178        // Line `i` occupies content-space [i·h, (i+1)·h], measured downward from
179        // the content top. The visible window is [offset, offset + inner_h].
180        let top = line as f64 * line_h;
181        let bottom = top + line_h;
182        let mut off = self.vbar.offset;
183        if top < off {
184            off = top; // caret above the window → scroll up to its line
185        } else if bottom > off + inner_h {
186            off = bottom - inner_h; // caret below → scroll down just enough
187        }
188        self.vbar.offset = off.clamp(0.0, self.max_scroll_y());
189        self.publish_scroll_offset();
190    }
191
192    /// Wheel handler. Positive `delta_y` means "see content above" (decrease
193    /// offset), matching the [`Event::MouseWheel`](crate::event::Event) sign
194    /// convention. Returns `true` when the offset actually moved.
195    pub(crate) fn scroll_by_wheel(&mut self, delta_y: f64) -> bool {
196        if self.max_scroll_y() <= 0.0 {
197            return false;
198        }
199        // Windows convention: one wheel notch scrolls 3 lines. `delta_y` arrives
200        // in line-units (1.0 == one notch; the platform adapter pre-scales
201        // trackpad pixel deltas into fractional line-units), so scaling by
202        // 3 * line-height gives a crisp 3-line notch while keeping pixel-precise
203        // trackpad panning smooth. This is more content-aware than ScrollView's
204        // flat 40 px/line, which felt slow for the editors' larger line pitch.
205        let line_h = if self.cached_line_h > 0.0 {
206            self.cached_line_h
207        } else {
208            self.font_size * 1.35
209        };
210        let moved = self
211            .vbar
212            .scroll_by(-delta_y * 3.0 * line_h, self.inner_height());
213        self.publish_scroll_offset();
214        moved
215    }
216
217    /// Begin a thumb drag if `pos` is on the thumb. Returns `true` when a drag
218    /// started (caller must then suppress text selection).
219    pub(crate) fn scrollbar_begin_drag(&mut self, pos: Point) -> bool {
220        if self.max_scroll_y() <= 0.0 {
221            return false;
222        }
223        let geom = self.v_scrollbar_geometry();
224        self.vbar
225            .begin_drag(pos, self.inner_height(), self.scroll_style(), geom)
226    }
227
228    /// Route a `MouseMove` through the bar: continue an in-progress drag, or
229    /// update hover state.
230    pub(crate) fn scrollbar_on_mouse_move(&mut self, pos: Point) -> ScrollMove {
231        let geom = self.v_scrollbar_geometry();
232        let vp = self.inner_height();
233        let style = self.scroll_style();
234        if self.vbar.dragging {
235            let moved = self.vbar.drag_to(pos, vp, style, geom);
236            self.publish_scroll_offset();
237            ScrollMove::Dragging(moved)
238        } else {
239            ScrollMove::Hover(self.vbar.update_hover(pos, vp, style, geom))
240        }
241    }
242
243    /// End any thumb drag. Returns `true` if a drag was in progress.
244    pub(crate) fn scrollbar_end_drag(&mut self) -> bool {
245        let was = self.vbar.dragging;
246        self.vbar.dragging = false;
247        was
248    }
249
250    /// Paint the vertical bar (thumb + track) using the global scroll style, so
251    /// it looks identical to `ScrollView`'s bars.
252    pub(crate) fn paint_scrollbar(&mut self, ctx: &mut dyn DrawCtx) {
253        let vp = self.inner_height();
254        let style = self.scroll_style();
255        let vis = self.scroll_visibility();
256        let geom = self.v_scrollbar_geometry();
257        if let Some(bar) = self.vbar.prepare_paint(vp, style, vis, geom) {
258            paint_prepared_scrollbar(ctx, bar);
259        }
260    }
261
262    /// `true` while the bar has a running hover/visibility animation, so the
263    /// widget keeps requesting frames until it settles.
264    pub(crate) fn scrollbar_animating(&self) -> bool {
265        self.vbar.animation_active()
266    }
267}