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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
//! A scrollable window over rendered lines (#495).
//!
//! Components that show more than fits (a pager, a preview pane, a long
//! list) keep a [`Viewport`] and render its visible lines; the painter
//! repaints only the cells that changed, so scrolling one line rewrites what
//! moved, not the whole screen. A viewport is also a [`Component`] on its
//! own: a minimal pager that returns where it was left.
//!
//! Since 0.0.7 its first lines can be sticky (#716): a table's header
//! stays on top while the rows under it scroll. On a page too short for
//! them all, the last that fit stay (a table's column names) and the ones
//! before scroll away with the rest.
use rich::{Segment, Style};
use crate::component::{Component, Context, Flow, View};
use crate::event::{Event, Key};
use crate::keymap::{Keymap, keys};
use crate::kit::ScrollState;
/// The keys of a [`Viewport`] run as a component, in context `viewport`:
/// [`ScrollState::keymap`]'s and `done` and `quit`.
pub fn viewport_keymap() -> Keymap {
let mut keymap = Keymap::new("viewport")
.bind("done", keys("enter"), "finish here")
.bind("quit", keys("q escape"), "quit");
keymap.extend(ScrollState::keymap("viewport"));
keymap
}
/// Lines, and the first one shown: a [`ScrollState`] over them.
#[derive(Clone, Debug)]
pub struct Viewport {
lines: Vec<Vec<Segment>>,
scroll: ScrollState,
/// Rows shown; `None` fills the context's height (less the status line
/// when the viewport runs as a component).
height: Option<usize>,
keymap: Keymap,
/// The first lines, shown on every page above the lines scrolled to.
sticky: usize,
}
impl Default for Viewport {
fn default() -> Self {
Viewport::new(Vec::new())
}
}
impl Viewport {
pub fn new(lines: Vec<Vec<Segment>>) -> Viewport {
Viewport {
scroll: ScrollState::new(lines.len()),
lines,
height: None,
keymap: viewport_keymap(),
sticky: 0,
}
}
/// Keep the first `rows` lines on every page, above the lines that
/// scroll: a table's header (#716). The scroll position
/// ([`offset`](Self::offset), [`scroll_to`](Self::scroll_to)) then
/// counts the lines under them. A page always keeps one row to scroll:
/// on one too short for every sticky line, the last that fit stay, and
/// the ones before them scroll away first, so every line is shown.
#[must_use]
pub fn sticky(mut self, rows: usize) -> Viewport {
self.set_sticky(rows);
self
}
/// Change the sticky lines (see [`sticky`](Self::sticky)), keeping the
/// offset where it still fits.
pub fn set_sticky(&mut self, rows: usize) {
self.sticky = rows;
self.scroll.set_len(self.lines.len() - self.stuck());
}
/// The sticky lines asked for.
pub fn sticky_rows(&self) -> usize {
self.sticky
}
/// The sticky lines there are.
fn stuck(&self) -> usize {
self.sticky.min(self.lines.len())
}
/// The sticky lines that stay on a page of `page` rows, the last of
/// them: one row at least is left to scroll.
fn pinned(&self, page: usize) -> usize {
self.stuck().min(page.saturating_sub(1))
}
/// The sticky lines that do not fit on a page of `page` rows: they
/// scroll away first, before the others stay.
fn unpinned(&self, page: usize) -> usize {
self.stuck() - self.pinned(page)
}
/// The rows of a page of `page` that scroll.
fn body(&self, page: usize) -> usize {
page - self.pinned(page)
}
/// The scroll state for a page of `page` rows: its length is the lines
/// less those that stay, so the last page ends with the last line.
fn scroll_for(&mut self, page: usize) -> (&mut ScrollState, usize) {
let (len, body) = (self.lines.len() - self.pinned(page), self.body(page));
self.scroll.set_len(len);
(&mut self.scroll, body)
}
/// Make `keys` do `action` (see [`viewport_keymap`]) when the viewport
/// runs as a component. Installed overrides (`viewport.page-down = n`)
/// apply too.
#[must_use]
pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Viewport {
self.keymap.rebind(action, keys);
self
}
/// Show `height` rows instead of filling the space.
#[must_use]
pub fn with_height(mut self, height: usize) -> Viewport {
self.height = Some(height);
self
}
/// Replace the lines, keeping the offset where it still fits.
pub fn set_lines(&mut self, lines: Vec<Vec<Segment>>) {
self.lines = lines;
self.scroll.set_len(self.lines.len() - self.stuck());
}
pub fn lines(&self) -> &[Vec<Segment>] {
&self.lines
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
/// The first line shown: with [sticky](Self::sticky) lines, the
/// number of lines scrolled past under them.
pub fn offset(&self) -> usize {
self.scroll.offset()
}
/// The scroll position.
pub fn scroll_state(&self) -> &ScrollState {
&self.scroll
}
/// Rows shown, given `available` rows of space.
pub fn page(&self, available: usize) -> usize {
self.height.unwrap_or(available).max(1)
}
/// Scroll by `delta` lines (negative: up), within the lines.
pub fn scroll(&mut self, delta: isize, page: usize) -> bool {
let (scroll, body) = self.scroll_for(page);
scroll.scroll(delta, body)
}
/// Scroll to the [offset](Self::offset) `line`, or as near as the last
/// page allows.
pub fn scroll_to(&mut self, line: usize, page: usize) -> bool {
let (scroll, body) = self.scroll_for(page);
scroll.scroll_to(line, body)
}
/// Scroll just enough that `line` (counted from the first line, sticky
/// lines included) is shown. A sticky line that stays needs no scroll.
pub fn show(&mut self, line: usize, page: usize) -> bool {
let (unpinned, pinned) = (self.unpinned(page), self.pinned(page));
// The offsets that show it: from the one that puts it last on the
// page, to the one that puts it first under the lines that stay
// (a sticky line that scrolls away: first on the page).
let first = (line + 1).saturating_sub(page);
let last = if line < unpinned {
line
} else if line < self.stuck() {
usize::MAX
} else {
line - pinned
};
let offset = self.offset();
if offset < first {
self.scroll_to(first, page)
} else if offset > last {
self.scroll_to(last, page)
} else {
false
}
}
/// Do one of [`ScrollState::keymap`]'s actions (`scroll-up`,
/// `page-down`, ...), for a component that looks its keys up in a
/// keymap. Returns whether `action` is a scroll action.
pub fn act(&mut self, action: &str, page: usize) -> bool {
let (scroll, body) = self.scroll_for(page);
scroll.act(action, body)
}
/// Move with the usual keys and the mouse wheel: arrows and `j`/`k` by a
/// line, PageUp/PageDown and Space by a page, Home/End and `g`/`G` to
/// the ends. Returns whether the event was a scroll key (moved or not).
pub fn handle_scroll(&mut self, event: &Event, page: usize) -> bool {
let (scroll, body) = self.scroll_for(page);
scroll.handle(event, body)
}
/// Whether the sticky lines that fit stay on a page of `page` rows:
/// once those that do not fit have scrolled away.
fn stays(&self, page: usize) -> bool {
self.offset() >= self.unpinned(page)
}
/// The index of the first line that scrolls on a page of `page` rows:
/// the first shown under the sticky lines that stay, or the first shown
/// when none stay yet.
pub fn top(&self, page: usize) -> usize {
let offset = self.offset();
if self.stays(page) {
offset + self.pinned(page)
} else {
offset
}
}
/// The lines that scroll, as shown on a page of `page` rows: every
/// line shown when none are [sticky](Self::sticky).
pub fn visible(&self, page: usize) -> &[Vec<Segment>] {
let end = (self.offset() + page).min(self.lines.len());
&self.lines[self.top(page).min(end)..end]
}
/// The lines shown on a page of `page` rows, each with its index: the
/// sticky lines that stay, then those scrolled to.
pub fn rows(&self, page: usize) -> Vec<(usize, &[Segment])> {
let staying = if self.stays(page) {
self.unpinned(page)..self.stuck()
} else {
0..0
};
let end = (self.offset() + page).min(self.lines.len());
staying
.chain(self.top(page).min(end)..end)
.map(|index| (index, self.lines[index].as_slice()))
.collect()
}
/// "lines 1–20 of 240", or "all 12 lines" when everything fits. With
/// sticky lines, the range is that of the lines under them, once
/// scrolled.
pub fn status(&self, page: usize) -> String {
let total = self.lines.len();
if total <= page {
return format!("all {total} lines");
}
let first = match self.offset() {
0 => 0,
_ => self.top(page),
};
let last = (self.offset() + page).min(total);
format!("lines {}–{last} of {total}", first + 1)
}
}
/// As a component: a pager over its lines with a status line. Enter
/// finishes with the offset reached; `q` and Escape cancel. Its keys come
/// from its [keymap](viewport_keymap), so they can be rebound.
impl Component for Viewport {
type Output = usize;
fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<usize> {
let page = self.page(context.height.saturating_sub(1));
let Some(key) = event.key() else {
// The wheel.
return if self.handle_scroll(event, page) {
Flow::Continue
} else {
Flow::Ignored
};
};
match self.keymap.action(key).map(str::to_string).as_deref() {
Some("done") => Flow::Done(self.offset()),
Some("quit") => Flow::Cancel,
Some(action) if self.act(action, page) => Flow::Continue,
_ => Flow::Ignored,
}
}
fn render(&self, context: &Context<'_>) -> View {
let page = self.page(context.height.saturating_sub(1));
let mut lines: Vec<Vec<Segment>> = self
.rows(page)
.into_iter()
.map(|(_, line)| line.to_vec())
.collect();
let status = format!("{} · ↑↓ PgUp PgDn · q quits", self.status(page));
lines.push(vec![Segment::new(
status,
Some(Style::parse("dim").expect("style")),
)]);
View::new(lines)
}
fn keymap(&self) -> Keymap {
self.keymap.clone()
}
fn default_value(&self) -> Option<usize> {
Some(self.offset())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Key, MouseKind};
fn lines(n: usize) -> Vec<Vec<Segment>> {
(1..=n)
.map(|i| vec![Segment::new(format!("line {i}"), None)])
.collect()
}
#[test]
fn scrolls_within_bounds() {
let mut viewport = Viewport::new(lines(10));
assert!(!viewport.scroll(-1, 4));
assert!(viewport.scroll(3, 4));
assert_eq!(viewport.offset(), 3);
assert!(viewport.scroll(100, 4));
assert_eq!(viewport.offset(), 6, "the last page is full");
assert_eq!(viewport.visible(4).len(), 4);
assert_eq!(viewport.status(4), "lines 7–10 of 10");
viewport.show(1, 4);
assert_eq!(viewport.offset(), 1);
viewport.show(8, 4);
assert_eq!(viewport.offset(), 5);
assert_eq!(Viewport::new(lines(3)).status(4), "all 3 lines");
}
#[test]
fn keys_and_wheel_move_it() {
let mut viewport = Viewport::new(lines(50));
let key = |name: &str| Event::Key(Key::parse(name).unwrap());
assert!(viewport.handle_scroll(&key("pagedown"), 10));
assert_eq!(viewport.offset(), 10);
assert!(viewport.handle_scroll(&key("j"), 10));
assert_eq!(viewport.offset(), 11);
assert!(viewport.handle_scroll(&key("G"), 10));
assert_eq!(viewport.offset(), 40);
assert!(viewport.handle_scroll(&key("home"), 10));
assert_eq!(viewport.offset(), 0);
assert!(!viewport.handle_scroll(&key("x"), 10));
let wheel = Event::Mouse(crate::event::Mouse {
kind: MouseKind::ScrollDown,
column: 0,
row: 0,
modifiers: Default::default(),
pixel: None,
});
assert!(viewport.handle_scroll(&wheel, 10));
assert_eq!(viewport.offset(), 3);
}
/// Run as a component, a viewport's keys follow its keymap, so
/// rebinding them works (0.0.14 release-test audit B).
#[test]
fn rebound_keys_scroll_it() {
use crate::headless::{self, Script};
let viewport = Viewport::new(lines(50)).rebind("scroll-down", [Key::char('n')]);
let (outcome, _) = headless::run(viewport, Script::new().keys("n n j enter"), 20, 6);
assert_eq!(outcome.unwrap(), crate::Outcome::Done(2));
let viewport = Viewport::new(lines(50)).rebind("done", [Key::char('x')]);
let (outcome, _) = headless::run(viewport, Script::new().keys("end x"), 20, 6);
assert_eq!(outcome.unwrap(), crate::Outcome::Done(45));
}
#[test]
fn replacing_lines_keeps_a_valid_offset() {
let mut viewport = Viewport::new(lines(50));
viewport.scroll_to(40, 10);
viewport.set_lines(lines(5));
assert_eq!(viewport.offset(), 4);
assert_eq!(viewport.visible(10).len(), 1);
}
}