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
//! The main view while a dataset is on its way in.
//!
//! It stands in for the table from the moment a load starts until that load installs
//! its dataset. Without it the previous dataset stayed on screen for the whole load —
//! one file's rows under another file's name, with only the control bar to say so.
//!
//! Drawn in the same family as the home screen: no boxes, centred, one accent. The
//! phase name is the progress indicator. It is a real, observable step ("Scanning
//! input", "Reading schema", "Loading buffer"), unlike the percentage beside it in
//! the control bar, which is a constant per phase.
use std::path::Path;
use crate::glyphs;
use crate::render::context::RenderContext;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph, Widget};
pub fn render(area: Rect, buf: &mut Buffer, app: &crate::App, ctx: &RenderContext) {
Clear.render(area, buf);
if area.width < 12 || area.height < 3 {
return;
}
let (phase, path, size) = match app.load_shown() {
Some((phase, _, path, size)) => (phase, path.map(Path::to_path_buf), size),
_ => ("Loading", None, 0),
};
// A directory of many files reads a footer from each before a row is shown, and on a
// few thousand that is seconds of a screen saying only "Reading schema". The count
// is what makes the wait legible: a number climbing is a wait, a number stopped is
// a problem. `App::loading_phase` decides it for the control bar too, so the two
// halves of the screen cannot say different things about one wait.
let phase = app.loading_phase(phase);
let phase = phase.as_ref();
let g = glyphs::get();
let frame = app.throbber_frame as usize % g.spinner.len();
// Paused on the download confirmation: nothing is in progress, so no spinner and
// no phase. The line stays so the file below it does not move.
let phase_line = if app.awaiting_open_confirmation() {
Line::from("")
} else {
Line::from(vec![
Span::styled(
format!("{} ", g.spinner[frame]),
Style::default().fg(ctx.throbber),
),
Span::styled(
// Truncated like the name and the location below it. The phase used to
// be a couple of words and always fitted; "Reading footers: 1,203 of
// 6,541" needs thirty-six columns, and cut by the terminal instead it
// reads "of 6" — a smaller number than the one it is counting towards.
truncate(
&format!("{phase}{}", g.ellipsis),
// The spinner and its two spaces come first on this line.
(area.width as usize).saturating_sub(3),
),
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
),
])
};
let mut lines = vec![phase_line, Line::from("")];
// The name first and the location under it: which file is coming is the question
// being answered, and a long path would push the name off a narrow screen.
if let Some(path) = path.as_ref() {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| crate::home::display_path(path));
lines.push(Line::from(Span::styled(
truncate(&name, area.width as usize),
Style::default().fg(ctx.text_primary),
)));
let mut detail = crate::home::display_path(path);
if size > 0 {
detail = format!("{detail} {}", crate::discover::format_size(size));
}
lines.push(Line::from(Span::styled(
truncate_start(&detail, area.width as usize),
Style::default().fg(ctx.text_secondary),
)));
}
// Centred vertically, a little above the middle: text sitting dead centre in a
// tall terminal reads as low.
let top = area
.y
.saturating_add(area.height.saturating_sub(lines.len() as u16) / 2)
.saturating_sub(1)
.max(area.y);
let height = (lines.len() as u16).min(area.height.saturating_sub(top - area.y));
let body = Rect {
x: area.x,
y: top,
width: area.width,
height,
};
Paragraph::new(lines).centered().render(body, buf);
}
/// Keep the head of a string, marking what was cut.
pub(crate) fn truncate(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_string();
}
let g = glyphs::get();
let keep = width.saturating_sub(g.ellipsis.chars().count());
text.chars().take(keep).collect::<String>() + g.ellipsis
}
/// Keep the tail of a path; the leaf is what says where the file is.
fn truncate_start(text: &str, width: usize) -> String {
let count = text.chars().count();
if count <= width {
return text.to_string();
}
let g = glyphs::get();
let keep = width.saturating_sub(g.ellipsis.chars().count());
let skip = count.saturating_sub(keep);
g.ellipsis.to_string() + &text.chars().skip(skip).collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::test_runtime;
fn cells(buf: &Buffer) -> String {
buf.content().iter().map(|c| c.symbol()).collect()
}
#[test]
fn a_load_with_no_room_to_draw_is_skipped_rather_than_panicking() {
// Terminals get resized to absurd sizes mid-load, and a load is exactly when
// the user cannot press anything to recover.
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, test_runtime());
app.set_loading_phase("Scanning input", 10);
for (w, h) in [(1, 1), (4, 2), (11, 40), (200, 1)] {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
render(area, &mut buf, &app, &RenderContext::for_test());
}
}
/// While the footers are being read the screen counts them, and stops when they
/// land.
///
/// "Reading schema" is true of that wait but says nothing about its length; a
/// directory of thousands of files spends seconds there. A number that climbs is a
/// wait, and a number that stops is a problem — neither is legible without it.
#[test]
fn the_footer_count_replaces_the_phase_while_it_is_running() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, test_runtime());
app.loading_for_tests(
Some(std::path::PathBuf::from("/tmp/blocks")),
2048,
"Reading schema",
40,
);
let area = Rect::new(0, 0, 60, 20);
let painted = |app: &crate::App| {
let mut buf = Buffer::empty(area);
render(area, &mut buf, app, &RenderContext::for_test());
cells(&buf)
};
assert!(
painted(&app).contains("Reading schema"),
"the phase, while nothing is being counted"
);
app.footer_progress().begin(6541);
for _ in 0..1203 {
app.footer_progress().advance();
}
// The count is taken once a frame rather than where it is shown; these tests
// paint the body alone, so they do for themselves what a whole frame does
// first. That the app does it is `test_the_control_bar_counts_the_footers_the
// _loading_screen_does`, which renders the App and not this function.
app.begin_frame();
let text = painted(&app);
assert!(
text.contains("Reading footers: 1,203 of 6,541"),
"the count, grouped so six thousand does not read as sixty: {text}"
);
assert!(
!text.contains("Reading schema"),
"and it replaces the phase rather than crowding in beside it: {text}"
);
// A new frame, because the count a frame shows is the one it started with: a
// pass that lands halfway down the screen does not change what the bottom of
// it says.
app.footer_progress().done();
app.begin_frame();
assert!(
painted(&app).contains("Reading schema"),
"once they have landed there is no wait left to count"
);
}
/// A listing counts the objects it has found, and gives way to the footers.
///
/// Listing a prefix of hundreds of thousands of objects is the longest wait of the
/// open, and it has no total, so the count is what says it is moving.
#[test]
fn the_listing_count_replaces_the_phase_while_it_is_running() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, test_runtime());
app.loading_for_tests(
Some(std::path::PathBuf::from("/tmp/by_station")),
0,
"Reading schema",
40,
);
let area = Rect::new(0, 0, 60, 20);
let painted = |app: &crate::App| {
let mut buf = Buffer::empty(area);
render(area, &mut buf, app, &RenderContext::for_test());
cells(&buf)
};
let progress = app.footer_progress().clone();
let listing = progress.listing();
for _ in 0..412_000 {
listing.advance();
}
app.begin_frame();
let text = painted(&app);
assert!(text.contains("Listing files: 412,000"), "{text}");
assert!(!text.contains("Reading schema"), "{text}");
// The two footers that follow the listing are counted as footers.
drop(listing);
app.footer_progress().begin(2);
app.begin_frame();
let text = painted(&app);
assert!(text.contains("Reading footers: 0 of 2"), "{text}");
assert!(!text.contains("Listing files"), "{text}");
app.footer_progress().done();
app.begin_frame();
assert!(painted(&app).contains("Reading schema"));
}
/// The count is cut with a mark at a width it does not fit, not by the terminal.
///
/// "Reading schema" is fourteen characters and always fitted; "Reading footers:
/// 1,203 of 6,541" needs thirty-five. Cut by the terminal instead of by the panel it
/// ends mid-number — "of 6" where it means "of 6,541", a smaller figure than the one
/// it is counting towards, which is the one way this line could actively mislead.
#[test]
fn the_footer_count_is_cut_with_a_mark_rather_than_by_the_edge() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, test_runtime());
app.loading_for_tests(
Some(std::path::PathBuf::from("/tmp/blocks")),
2048,
"Reading schema",
40,
);
app.footer_progress().begin(6541);
for _ in 0..1203 {
app.footer_progress().advance();
}
app.begin_frame();
let ellipsis = crate::glyphs::get().ellipsis;
// Every width the panel draws at, not a handful: the earlier list skipped the
// band either side of where the count stops fitting, which is exactly where a
// truncation is wrong if it is wrong anywhere.
for width in 12u16..=60 {
let area = Rect::new(0, 0, width, 20);
let mut buf = Buffer::empty(area);
render(area, &mut buf, &app, &RenderContext::for_test());
// The panel is centred, so the phase is not always on row zero.
let line = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.find(|row| row.contains("Read"))
.map(|row| row.trim_end().to_string())
.unwrap_or_else(|| panic!("no phase line at {width}"));
// Every phase line ends with the mark by design, so a cut one and a whole
// one look alike — which is the point: what must never happen is a cut
// with *nothing* saying so, reading as a whole number smaller than the
// real one. A number cut at some width is unavoidable; "of 6,5" is all
// that fits in thirty-three columns.
assert!(
line.ends_with(ellipsis),
"at {width} the line ends with nothing to say it may be cut: {line:?}"
);
// And where there is room for the whole count, it is not cut anyway. The
// room needed is derived rather than counted out here because it is not a
// constant: the ASCII glyph set spells the mark "..." rather than "…",
// two columns more, so a number written in here would be right under one
// locale and wrong under the other.
let whole = format!("Reading footers: 1,203 of 6,541{ellipsis}")
.chars()
.count()
+ 3; // the spinner and its two spaces
if width as usize >= whole {
assert!(
line.contains("1,203 of 6,541"),
"at {width} the whole count fits: {line:?}"
);
}
}
}
#[test]
fn the_phase_and_the_file_are_both_on_screen() {
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, test_runtime());
app.loading_for_tests(
Some(std::path::PathBuf::from("/tmp/quarterly.parquet")),
2048,
"Reading schema",
40,
);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
render(area, &mut buf, &app, &RenderContext::for_test());
let text = cells(&buf);
assert!(text.contains("Reading schema"), "phase missing: {text:?}");
assert!(text.contains("quarterly.parquet"), "file missing: {text:?}");
assert!(text.contains("2.0 KB"), "size missing: {text:?}");
}
}