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
//! A cursor that knows what is on screen.
//!
//! A config pane has 40 fields under nine headers; a 30-line terminal
//! shows a fraction of them. This holds the offset and scroll-into-view a
//! bare `cursor: usize` cannot.
//!
//! A viewport that does not know its height (`rows == 0`) never scrolls.
//!
//! This counts rows, not lines: a screen whose chrome costs lines cannot
//! ask it how much is off screen and get an answer about its own body.
/// A cursor, an offset, and the number of rows the terminal shows.
///
/// `Debug` is derived (IR-41): three integers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Viewport {
cursor: usize,
offset: usize,
rows: usize,
}
impl Viewport {
/// At the top, height unknown.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// The selected row's index into the list.
#[must_use]
pub fn cursor(&self) -> usize {
self.cursor
}
/// The first row that is drawn.
#[must_use]
pub fn offset(&self) -> usize {
self.offset
}
/// Records the terminal's height, and pulls the cursor back into view
/// if the terminal shrank under it.
pub fn set_rows(&mut self, rows: usize, len: usize) {
self.rows = rows;
self.ensure_visible(len);
}
/// Moves by `delta`, clamped to `0..len` rather than wrapping, the same
/// rule the flock table follows.
pub fn move_by(&mut self, delta: isize, len: usize) {
if len == 0 {
self.cursor = 0;
self.offset = 0;
return;
}
let next = self.cursor as isize + delta;
self.cursor = next.clamp(0, len as isize - 1) as usize;
self.ensure_visible(len);
}
/// Jumps to `index`, clamped.
pub fn move_to(&mut self, index: usize, len: usize) {
if len == 0 {
self.cursor = 0;
self.offset = 0;
return;
}
self.cursor = index.min(len - 1);
self.ensure_visible(len);
}
/// Clamps to a list that may have shrunk since the last move.
pub fn clamp(&mut self, len: usize) {
self.move_by(0, len);
}
/// Pulls the offset back to whatever keeps the cursor visible, and caps
/// it to `len - rows`. Every entry point above routes through here
/// with its own `len`, so a list that shrinks between two calls (a
/// `move_by` with no `clamp` in between) cannot leave a stale offset
/// past the end of the shorter list, the same way `move_by` and
/// `move_to` already cannot leave a stale cursor.
fn ensure_visible(&mut self, len: usize) {
if self.rows == 0 {
return;
}
if self.cursor < self.offset {
self.offset = self.cursor;
} else if self.cursor >= self.offset + self.rows {
self.offset = self.cursor + 1 - self.rows;
}
self.offset = self.offset.min(len.saturating_sub(self.rows));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_viewport_that_does_not_know_its_height_never_scrolls() {
let mut v = Viewport::new();
v.move_by(50, 100);
assert_eq!(v.cursor(), 50);
assert_eq!(v.offset(), 0);
}
#[test]
fn moving_past_the_bottom_pulls_the_offset_so_the_cursor_is_the_last_visible_row() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(15, 100);
assert_eq!(v.cursor(), 15);
assert_eq!(v.offset(), 6, "rows 6..=15 are visible");
}
#[test]
fn moving_back_above_the_top_pulls_the_offset_to_the_cursor() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(30, 100);
v.move_by(-28, 100);
assert_eq!(v.cursor(), 2);
assert_eq!(v.offset(), 2);
}
#[test]
fn the_cursor_clamps_to_the_list_rather_than_wrapping() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(-5, 100);
assert_eq!(v.cursor(), 0);
v.move_by(500, 100);
assert_eq!(v.cursor(), 99);
assert_eq!(v.offset(), 90);
}
#[test]
fn an_empty_list_leaves_the_cursor_and_offset_at_zero() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(3, 0);
assert_eq!((v.cursor(), v.offset()), (0, 0));
}
#[test]
fn shrinking_the_list_under_the_cursor_clamps_it_back() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_to(45, 100);
v.clamp(20);
assert_eq!(v.cursor(), 19);
assert_eq!(v.offset(), 10);
}
#[test]
fn a_shorter_terminal_brings_the_cursor_back_into_view() {
let mut v = Viewport::new();
v.set_rows(30, 100);
v.move_to(25, 100);
assert_eq!(v.offset(), 0);
v.set_rows(10, 100);
assert_eq!(v.offset(), 16, "the cursor is still the last visible row");
}
/// The shape `content_lines`' env sub-screen exercises once it can
/// remove a key out from under the cursor. `move_by` must catch the
/// stale offset on its own; nothing here ever calls `clamp`.
#[test]
fn a_move_after_the_list_shrinks_catches_the_stale_offset_without_a_clamp() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_to(90, 100);
v.move_by(0, 20);
assert_eq!(v.offset(), 10);
assert_eq!(v.cursor(), 19);
}
}