1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//! Scrollbar scroll state — ported 1:1 from mlc ScrollState.
//!
//! No velocity, no inertia, no opacity animation, no timestamp.
//! Offset is in pixels from top (absolute, not 0..1 normalised).
/// Per-scrollable scroll state. Cheap to clone; store one per scrollable widget.
#[derive(Debug, Clone, Default)]
pub struct ScrollState {
/// Current scroll offset in pixels from top.
pub offset: f64,
/// Whether the scrollbar thumb is being dragged.
pub is_dragging: bool,
/// Absolute Y position where the drag started (screen coordinates).
pub drag_start_y: Option<f64>,
/// Scroll offset at drag start — used to compute delta.
pub drag_start_offset: Option<f64>,
}
impl ScrollState {
pub fn new() -> Self {
Self::default()
}
/// Reset to initial state — zero offset, no drag.
pub fn reset(&mut self) {
*self = Self::default();
}
/// Begin a thumb drag. Captures current Y and current offset.
pub fn start_drag(&mut self, y: f64) {
self.is_dragging = true;
self.drag_start_y = Some(y);
self.drag_start_offset = Some(self.offset);
}
/// End the thumb drag — clears drag state, offset is preserved.
pub fn end_drag(&mut self) {
self.is_dragging = false;
self.drag_start_y = None;
self.drag_start_offset = None;
}
/// Handle one mouse-wheel notch.
///
/// `delta_y` — signed direction after caller sign-flip. Positive = scroll
/// down (offset increases). Per-notch movement: `|delta_y| * 10.0` pixels.
///
/// Returns `true` if the event was consumed (content is taller than viewport).
pub fn handle_wheel(&mut self, delta_y: f64, content_height: f64, viewport_height: f64) -> bool {
if content_height <= viewport_height {
return false;
}
let max_scroll = (content_height - viewport_height).max(0.0);
let scroll_step = 10.0;
self.offset = (self.offset + delta_y * scroll_step).clamp(0.0, max_scroll);
true
}
/// Continue an in-progress thumb drag to absolute position `y`.
///
/// Uses a linear mapping from track-space delta to content-space delta,
/// anchored at the drag-start position so the thumb follows the cursor
/// precisely.
pub fn handle_drag(
&mut self,
y: f64,
track_height: f64,
content_height: f64,
viewport_height: f64,
) {
if !self.is_dragging {
return;
}
let Some(start_y) = self.drag_start_y else { return };
let Some(start_offset) = self.drag_start_offset else { return };
let max_scroll = (content_height - viewport_height).max(0.0);
if max_scroll <= 0.0 {
return;
}
// Drag math minimum (20 px) is independent of the render minimum (varies
// per style preset). Matches mlc handle_drag exactly.
let handle_height = (viewport_height / content_height * track_height).max(20.0);
let scroll_range = track_height - handle_height;
if scroll_range <= 0.0 {
return;
}
let dy = y - start_y;
let scroll_delta = dy / scroll_range * max_scroll;
self.offset = (start_offset + scroll_delta).clamp(0.0, max_scroll);
}
/// Proportional-jump on track click — snaps offset to the clicked position
/// within the track. NOT a page jump.
pub fn handle_track_click(
&mut self,
click_y: f64,
track_y: f64,
track_height: f64,
content_height: f64,
viewport_height: f64,
) {
let max_scroll = (content_height - viewport_height).max(0.0);
if max_scroll <= 0.0 {
return;
}
let relative_y = (click_y - track_y) / track_height;
self.offset = (relative_y * max_scroll).clamp(0.0, max_scroll);
}
/// Clamp the stored offset to the valid range. Call after content height
/// shrinks (e.g. filter narrowed a list) to avoid stale out-of-range offset.
pub fn clamp(&mut self, content_height: f64, viewport_height: f64) {
let max_scroll = (content_height - viewport_height).max(0.0);
self.offset = self.offset.clamp(0.0, max_scroll);
}
/// Absolute programmatic scroll — instant, no animation.
pub fn scroll_to(&mut self, offset: f64, content_height: f64, viewport_height: f64) {
let max_scroll = (content_height - viewport_height).max(0.0);
self.offset = offset.clamp(0.0, max_scroll);
}
/// Scroll just enough to make the item at `item_y` with `item_height`
/// fully visible. No-op if item is already in view.
pub fn ensure_visible(
&mut self,
item_y: f64,
item_height: f64,
viewport_height: f64,
content_height: f64,
) {
let max_scroll = (content_height - viewport_height).max(0.0);
if item_y < self.offset {
self.offset = item_y.max(0.0);
} else if item_y + item_height > self.offset + viewport_height {
self.offset = (item_y + item_height - viewport_height).clamp(0.0, max_scroll);
}
}
/// Whether the content overflows the viewport (scrollbar should be drawn).
pub fn is_scrollable(&self, content_height: f64, viewport_height: f64) -> bool {
content_height > viewport_height
}
/// Compute thumb geometry: `(thumb_y_offset_within_track, thumb_length)`.
///
/// `track_height` — rendered track pixel height.
/// `min_thumb_length` — style-defined minimum.
pub fn thumb_geometry(
&self,
content_height: f64,
viewport_height: f64,
track_height: f64,
min_thumb_length: f64,
) -> (f64, f64) {
if content_height <= 0.0 {
return (0.0, track_height);
}
let visible_ratio = (viewport_height / content_height).clamp(0.0, 1.0);
let thumb_len = (track_height * visible_ratio).max(min_thumb_length).min(track_height);
let max_scroll = (content_height - viewport_height).max(0.0);
let scroll_ratio = if max_scroll > 0.0 {
(self.offset / max_scroll).clamp(0.0, 1.0)
} else {
0.0
};
let available = (track_height - thumb_len).max(0.0);
let thumb_y = available * scroll_ratio;
(thumb_y, thumb_len)
}
}