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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
//! Shared window and scrollbar primitives for virtualized collections.
use std::ops::Range;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use crate::geometry::Size;
use crate::style::StyleRole;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
/// A clamped contiguous window into a larger logical collection.
///
/// `VirtualWindow` contains no data and owns no persistent state. A host keeps
/// its offset or selection, creates a window for the current frame, and uses
/// [`range`](Self::range) to fetch only the visible records. Built-in lists,
/// tables, line scrolling, item scrolling, and viewports use the same math.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct VirtualWindow {
total: usize,
start: usize,
len: usize,
}
impl VirtualWindow {
/// Create a window over `total` entries, showing at most `visible` entries
/// from `start`. Both the length and start are clamped to the collection.
pub fn new(total: usize, visible: usize, start: usize) -> Self {
let len = visible.min(total);
let start = start.min(Self::max_start_for(total, len));
Self { total, start, len }
}
/// Largest valid start for `visible` entries in a collection of `total`.
///
/// This is the allocation-free bound used by input handlers before a full
/// frame window exists.
pub const fn max_start_for(total: usize, visible: usize) -> usize {
total.saturating_sub(visible)
}
/// Create a window centered on `anchor` where possible.
///
/// A missing anchor starts at the beginning. An out-of-range anchor is
/// clamped to the last entry, so stale host selection cannot overflow the
/// range calculation.
pub fn around(total: usize, visible: usize, anchor: Option<usize>) -> Self {
if total == 0 {
return Self::default();
}
let len = visible.min(total);
let anchor = anchor.unwrap_or(0).min(total - 1);
Self::new(total, len, anchor.saturating_sub(len / 2))
}
/// Create the window that keeps `anchor` visible with the smallest possible
/// adjustment of `start`.
///
/// `start` is the caller's current top entry — a host's persistent offset,
/// or `0` for a stateless caller. The window does not move while the anchor
/// is inside it; when the anchor leaves, it trails the anchor by exactly one
/// edge. This is the complement of [`around`](Self::around), which recenters
/// on every move.
///
/// A missing anchor leaves `start` alone (clamped to the collection); an
/// out-of-range anchor is clamped to the last entry.
pub fn keeping(total: usize, visible: usize, start: usize, anchor: Option<usize>) -> Self {
if total == 0 {
return Self::default();
}
let len = visible.min(total);
let mut start = start.min(Self::max_start_for(total, len));
if len > 0
&& let Some(anchor) = anchor
{
let anchor = anchor.min(total - 1);
if anchor < start {
start = anchor;
} else if anchor >= start.saturating_add(len) {
start = anchor.saturating_add(1).saturating_sub(len);
}
}
Self::new(total, len, start)
}
/// Total entries in the logical collection.
pub const fn total(self) -> usize {
self.total
}
/// Absolute index of the first visible entry.
pub const fn start(self) -> usize {
self.start
}
/// Number of visible entries.
pub const fn len(self) -> usize {
self.len
}
/// Whether the visible window is empty.
pub const fn is_empty(self) -> bool {
self.len == 0
}
/// One-past-the-end absolute index of the visible entries.
pub fn end(self) -> usize {
self.start.saturating_add(self.len).min(self.total)
}
/// Absolute index range of the visible entries.
pub fn range(self) -> Range<usize> {
self.start..self.end()
}
/// Largest valid start for a window of this length.
pub fn max_start(self) -> usize {
Self::max_start_for(self.total, self.len)
}
/// Whether some entries lie outside the window.
pub const fn overflows(self) -> bool {
self.total > self.len
}
/// Whether `index` is visible in this window.
pub fn contains(self, index: usize) -> bool {
self.range().contains(&index)
}
}
/// How a stateless component places its window around the selection.
///
/// Only consulted when a component resolves its own window — a host that
/// supplies an explicit window (`visible_window`, or a `source_window` it
/// already sliced) has already made this decision itself.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SelectionAnchor {
/// Keep the selection mid-window, recentering on every move
/// ([`VirtualWindow::around`]). The default.
#[default]
Center,
/// Move the window only when the selection would leave it
/// ([`VirtualWindow::keeping`]).
///
/// A stateless component starts at the top of the collection, so the
/// selection rides the bottom edge moving down and the top edge moving up —
/// the familiar list-scrolling policy.
Edge,
}
impl SelectionAnchor {
/// Resolve the stateless window for `selected` under this policy.
pub(crate) fn window(
self,
total: usize,
visible: usize,
selected: Option<usize>,
) -> VirtualWindow {
match self {
Self::Center => VirtualWindow::around(total, visible, selected),
Self::Edge => VirtualWindow::keeping(total, visible, 0, selected),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Orientation {
Vertical,
Horizontal,
}
/// A scrollbar for a [`VirtualWindow`].
///
/// The same component draws vertical and horizontal bars, uses the active
/// theme by default, and permits local glyph/style overrides. It draws nothing
/// when the whole collection fits in the window.
///
/// 
pub struct Scrollbar {
window: VirtualWindow,
orientation: Orientation,
track_glyph: char,
thumb_glyph: char,
track_style: Option<Style>,
thumb_style: Option<Style>,
}
impl Scrollbar {
/// Create a vertical scrollbar for `window`.
pub fn vertical(window: VirtualWindow) -> Self {
Self {
window,
orientation: Orientation::Vertical,
track_glyph: '│',
thumb_glyph: '█',
track_style: None,
thumb_style: None,
}
}
/// Create a horizontal scrollbar for `window`.
pub fn horizontal(window: VirtualWindow) -> Self {
Self {
window,
orientation: Orientation::Horizontal,
track_glyph: '─',
thumb_glyph: '━',
track_style: None,
thumb_style: None,
}
}
/// Replace the track glyph.
pub fn track_glyph(mut self, glyph: char) -> Self {
self.track_glyph = glyph;
self
}
/// Replace the thumb glyph.
pub fn thumb_glyph(mut self, glyph: char) -> Self {
self.thumb_glyph = glyph;
self
}
/// Override the track style for this bar.
pub fn track_style(mut self, style: Style) -> Self {
self.track_style = Some(style);
self
}
/// Override the thumb style for this bar.
pub fn thumb_style(mut self, style: Style) -> Self {
self.thumb_style = Some(style);
self
}
fn track_len(&self, area: Rect) -> u16 {
match self.orientation {
Orientation::Vertical => area.height,
Orientation::Horizontal => area.width,
}
}
fn thumb(&self, track_len: u16) -> (u16, u16) {
if track_len == 0 || self.window.total == 0 {
return (0, 0);
}
let track = u128::from(track_len);
let thumb_len = ((track * self.window.len as u128) / self.window.total as u128)
.max(1)
.min(track) as u16;
let travel = track_len.saturating_sub(thumb_len);
let max_start = self.window.max_start().max(1);
let thumb_start = ((self.window.start.min(max_start) as u128 * u128::from(travel))
/ max_start as u128) as u16;
(thumb_start, thumb_len)
}
}
impl View for Scrollbar {
fn measure(&self, available: Size, _ctx: &RenderCtx) -> Size {
let len = self.window.len.min(u16::MAX as usize) as u16;
match self.orientation {
Orientation::Vertical => Size::new(available.width.min(1), available.height.min(len)),
Orientation::Horizontal => Size::new(available.width.min(len), available.height.min(1)),
}
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.is_empty() || !self.window.overflows() {
return;
}
let track_len = self.track_len(area);
let (thumb_start, thumb_len) = self.thumb(track_len);
let track_style = self.track_style.unwrap_or_else(|| {
ctx.style(StyleRole::SCROLLBAR_TRACK)
.apply(Style::default().fg(ctx.theme.dim))
});
let thumb_style = self.thumb_style.unwrap_or_else(|| {
ctx.style(StyleRole::SCROLLBAR_THUMB)
.apply(Style::default().fg(ctx.theme.muted))
});
for index in 0..track_len {
let thumb = index >= thumb_start && index < thumb_start.saturating_add(thumb_len);
let (glyph, style) = if thumb {
(self.thumb_glyph, thumb_style)
} else {
(self.track_glyph, track_style)
};
let (x, y) = match self.orientation {
Orientation::Vertical => (area.x, area.y.saturating_add(index)),
Orientation::Horizontal => (area.x.saturating_add(index), area.y),
};
surface.set(x, y, glyph, style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A 20-row collection in a 5-row pane, the two policies side by side.
#[test]
fn keeping_trails_the_anchor_while_around_recenters() {
let edge = |anchor: usize| VirtualWindow::keeping(20, 5, 0, Some(anchor)).start();
let center = |anchor: usize| VirtualWindow::around(20, 5, Some(anchor)).start();
// Inside the first window: neither policy can scroll yet.
assert_eq!(edge(2), 0);
assert_eq!(center(2), 0);
// Past the bottom edge: `keeping` trails by one row, `around` centers.
assert_eq!(edge(7), 3);
assert_eq!(center(7), 5);
// At the end both clamp to the last full window.
assert_eq!(edge(19), 15);
assert_eq!(center(19), 15);
}
#[test]
fn keeping_holds_start_for_an_anchor_already_inside_the_window() {
// Moving 7 -> 6 with the window already at 3 changes nothing...
assert_eq!(VirtualWindow::keeping(20, 5, 3, Some(6)).start(), 3);
// ...while a stateless caller re-derives the trailing window.
assert_eq!(VirtualWindow::keeping(20, 5, 0, Some(6)).start(), 2);
// Above the window, the anchor becomes the top row.
assert_eq!(VirtualWindow::keeping(20, 5, 10, Some(4)).start(), 4);
}
#[test]
fn keeping_survives_degenerate_input() {
assert_eq!(
VirtualWindow::keeping(0, 5, 9, Some(3)),
VirtualWindow::default()
);
// No anchor leaves the start alone, clamped to the collection.
assert_eq!(VirtualWindow::keeping(20, 5, 99, None).start(), 15);
// A zero-height pane can show no anchor at all, so the start is simply
// carried through — the same as `new` with an empty window.
let empty = VirtualWindow::keeping(20, 0, 4, Some(19));
assert!(empty.is_empty());
assert_eq!(empty.start(), 4);
// An out-of-range anchor clamps to the last entry.
assert_eq!(
VirtualWindow::keeping(20, 5, 0, Some(usize::MAX)).start(),
15
);
}
#[test]
fn selection_anchor_dispatches_to_the_two_policies() {
assert_eq!(SelectionAnchor::default(), SelectionAnchor::Center);
assert_eq!(SelectionAnchor::Center.window(20, 5, Some(7)).start(), 5);
assert_eq!(SelectionAnchor::Edge.window(20, 5, Some(7)).start(), 3);
}
}