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
38impl TextArea {
39 /// Inner content height (widget height minus vertical padding).
40 pub(crate) fn inner_height(&self) -> f64 {
41 (self.bounds.height - self.padding * 2.0).max(0.0)
42 }
43
44 /// Total wrapped-content height at the current line count.
45 pub(crate) fn content_height(&self) -> f64 {
46 self.cached_lines.len() as f64 * self.cached_line_h
47 }
48
49 /// Furthest the content can scroll (0 when everything fits).
50 pub(crate) fn max_scroll_y(&self) -> f64 {
51 (self.content_height() - self.inner_height()).max(0.0)
52 }
53
54 /// Current vertical scroll offset (0 = top). Exposed for tests/inspectors.
55 pub fn scroll_offset(&self) -> f64 {
56 self.vbar.offset
57 }
58
59 /// Publish this widget's live vertical scroll offset (in px from the top,
60 /// the same value [`scroll_offset`](Self::scroll_offset) reports) into a
61 /// shared cell whenever it changes. Exists so a *sibling* widget outside the
62 /// editor's subtree can mirror the viewport — the Code Editor demo's
63 /// line-number gutter uses it to keep the numbers aligned with the scrolled
64 /// text, which it otherwise cannot see. The cell is refreshed at every offset
65 /// mutation (wheel, thumb drag, scroll-to-caret, relayout clamp), so it is
66 /// current before the next frame paints.
67 pub fn with_scroll_watch(mut self, watch: Rc<Cell<f64>>) -> Self {
68 watch.set(self.vbar.offset);
69 self.scroll_watch = Some(watch);
70 self
71 }
72
73 /// Number of whole visual lines that fit in the viewport — the distance
74 /// PageUp/PageDown move the caret. Always at least 1 so the caret advances
75 /// even in a viewport shorter than one line.
76 pub(crate) fn page_lines(&self) -> usize {
77 if self.cached_line_h <= 0.0 {
78 return 1;
79 }
80 ((self.inner_height() / self.cached_line_h).floor() as usize).max(1)
81 }
82
83 fn scroll_style(&self) -> ScrollBarStyle {
84 current_scroll_style()
85 }
86 fn scroll_visibility(&self) -> ScrollBarVisibility {
87 current_scroll_visibility()
88 }
89
90 /// Mirror the current `vbar.offset` into the optional scroll-watch cell.
91 /// Called from every site that moves the offset so a sibling widget (the
92 /// line-number gutter) never paints a frame behind the text. See
93 /// [`TextArea::with_scroll_watch`].
94 fn publish_scroll_offset(&self) {
95 if let Some(watch) = &self.scroll_watch {
96 watch.set(self.vbar.offset);
97 }
98 }
99
100 /// Refresh the axis with the current content height and re-clamp the offset.
101 /// Called each layout after the wrap cache is rebuilt.
102 pub(crate) fn sync_scroll(&mut self) {
103 self.vbar.enabled = true;
104 self.vbar.content = self.content_height();
105 self.vbar.clamp_offset(self.inner_height());
106 self.publish_scroll_offset();
107 }
108
109 /// Vertical track geometry in widget-local Y-up coords. The bar hugs the
110 /// right inner edge; the track spans the padded inner height.
111 fn v_scrollbar_geometry(&self) -> ScrollbarGeometry {
112 ScrollbarGeometry {
113 orientation: ScrollbarOrientation::Vertical,
114 track_start: self.padding,
115 track_end: (self.bounds.height - self.padding).max(self.padding),
116 cross_end: (self.bounds.width - 2.0).max(0.0),
117 hit_margin: DEFAULT_GRAB_MARGIN,
118 }
119 }
120
121 /// Scroll so the caret's visual line is fully visible. Re-wraps first (the
122 /// cache may be dirty after an edit) so the line index and content height
123 /// are current. Called after every cursor-affecting key.
124 pub(crate) fn ensure_cursor_visible(&mut self) {
125 let inner_w = self.inner_width();
126 self.refresh_wrap(inner_w);
127 let line_h = self.cached_line_h;
128 if line_h <= 0.0 {
129 return;
130 }
131 let inner_h = self.inner_height();
132 let cursor = self.edit.borrow().cursor;
133 let line = self.line_for_cursor(cursor);
134 // Line `i` occupies content-space [i·h, (i+1)·h], measured downward from
135 // the content top. The visible window is [offset, offset + inner_h].
136 let top = line as f64 * line_h;
137 let bottom = top + line_h;
138 let mut off = self.vbar.offset;
139 if top < off {
140 off = top; // caret above the window → scroll up to its line
141 } else if bottom > off + inner_h {
142 off = bottom - inner_h; // caret below → scroll down just enough
143 }
144 self.vbar.offset = off.clamp(0.0, self.max_scroll_y());
145 self.publish_scroll_offset();
146 }
147
148 /// Wheel handler. Positive `delta_y` means "see content above" (decrease
149 /// offset), matching the [`Event::MouseWheel`](crate::event::Event) sign
150 /// convention. Returns `true` when the offset actually moved.
151 pub(crate) fn scroll_by_wheel(&mut self, delta_y: f64) -> bool {
152 if self.max_scroll_y() <= 0.0 {
153 return false;
154 }
155 // Windows convention: one wheel notch scrolls 3 lines. `delta_y` arrives
156 // in line-units (1.0 == one notch; the platform adapter pre-scales
157 // trackpad pixel deltas into fractional line-units), so scaling by
158 // 3 * line-height gives a crisp 3-line notch while keeping pixel-precise
159 // trackpad panning smooth. This is more content-aware than ScrollView's
160 // flat 40 px/line, which felt slow for the editors' larger line pitch.
161 let line_h = if self.cached_line_h > 0.0 {
162 self.cached_line_h
163 } else {
164 self.font_size * 1.35
165 };
166 let moved = self
167 .vbar
168 .scroll_by(-delta_y * 3.0 * line_h, self.inner_height());
169 self.publish_scroll_offset();
170 moved
171 }
172
173 /// Begin a thumb drag if `pos` is on the thumb. Returns `true` when a drag
174 /// started (caller must then suppress text selection).
175 pub(crate) fn scrollbar_begin_drag(&mut self, pos: Point) -> bool {
176 if self.max_scroll_y() <= 0.0 {
177 return false;
178 }
179 let geom = self.v_scrollbar_geometry();
180 self.vbar
181 .begin_drag(pos, self.inner_height(), self.scroll_style(), geom)
182 }
183
184 /// Route a `MouseMove` through the bar: continue an in-progress drag, or
185 /// update hover state.
186 pub(crate) fn scrollbar_on_mouse_move(&mut self, pos: Point) -> ScrollMove {
187 let geom = self.v_scrollbar_geometry();
188 let vp = self.inner_height();
189 let style = self.scroll_style();
190 if self.vbar.dragging {
191 let moved = self.vbar.drag_to(pos, vp, style, geom);
192 self.publish_scroll_offset();
193 ScrollMove::Dragging(moved)
194 } else {
195 ScrollMove::Hover(self.vbar.update_hover(pos, vp, style, geom))
196 }
197 }
198
199 /// End any thumb drag. Returns `true` if a drag was in progress.
200 pub(crate) fn scrollbar_end_drag(&mut self) -> bool {
201 let was = self.vbar.dragging;
202 self.vbar.dragging = false;
203 was
204 }
205
206 /// Paint the vertical bar (thumb + track) using the global scroll style, so
207 /// it looks identical to `ScrollView`'s bars.
208 pub(crate) fn paint_scrollbar(&mut self, ctx: &mut dyn DrawCtx) {
209 let vp = self.inner_height();
210 let style = self.scroll_style();
211 let vis = self.scroll_visibility();
212 let geom = self.v_scrollbar_geometry();
213 if let Some(bar) = self.vbar.prepare_paint(vp, style, vis, geom) {
214 paint_prepared_scrollbar(ctx, bar);
215 }
216 }
217
218 /// `true` while the bar has a running hover/visibility animation, so the
219 /// widget keeps requesting frames until it settles.
220 pub(crate) fn scrollbar_animating(&self) -> bool {
221 self.vbar.animation_active()
222 }
223}