steid

@jamesgill /

Rust10.3 KBCode·Blame·History·Raw
1//! One paging control, and the arithmetic every paged list shares.
2//!
3//! **Offset paging, `?page=N`, 1-based, everywhere** — git-backed lists included. See
4//! [ui.md](../../../plans/ui.md#paging) for why the git lists do not use a keyset
5//! cursor: `git log` walks a date-interleaved history rather than a chain, so "the
6//! commits before <sha>" is not the same set as "the rest of this log" the moment a
7//! merge is involved, and a cursor would silently drop the other parent's commits.
8//! `--skip` is git's own answer and is exact.
9//!
10//! A page number is *clamped*, never refused: `?page=0`, `?page=nonsense` and
11//! `?page=400` on a four-page list are all links somebody may have mangled or that a
12//! rewritten branch has invalidated, and the honest answer to each is the first page,
13//! not a 400 or a 500.
14//!
15//! The control renders nothing at all when there is one page. A line saying "page 1 of
16//! 1" is noise, and a list short enough to fit was never paged as far as the reader is
17//! concerned.
18
19use topcoat::{
20 Result,
21 view::{component, view},
22};
23
24/// How many numbered pages sit either side of the current one before the gap.
25const WINDOW: usize = 1;
26
27/// One entry on the paging line.
28///
29/// Three shapes, distinguished without a second enum: a link (`href`), the page you are
30/// on (`current`), and the gap marker, which is neither.
31pub(super) struct Step {
32 pub label: String,
33 pub href: Option<String>,
34 pub current: bool,
35}
36
37impl Step {
38 fn link(label: impl Into<String>, href: String) -> Self {
39 Self {
40 label: label.into(),
41 href: Some(href),
42 current: false,
43 }
44 }
45
46 fn here(label: impl Into<String>) -> Self {
47 Self {
48 label: label.into(),
49 href: None,
50 current: true,
51 }
52 }
53
54 fn gap() -> Self {
55 Self {
56 label: "…".to_owned(),
57 href: None,
58 current: false,
59 }
60 }
61}
62
63/// The paging line for one list, ready to render.
64///
65/// Built by the page, not by the component, because building it needs the list's own
66/// URL shape — a filter, a revision, a query string that has to survive the jump.
67pub(super) struct Pages {
68 pub steps: Vec<Step>,
69}
70
71impl Pages {
72 /// "← Previous · 1 2 3 … 7 · Next →" over a known total.
73 ///
74 /// `url` builds the link for a page number, so a list with filters carries them
75 /// across: the caller owns the query string and this owns the arithmetic.
76 ///
77 /// Empty — and so invisible — when `total` is one page or fewer. Disabled ends are
78 /// absent rather than greyed: there is no page before the first, and a control that
79 /// renders one anyway is asking to be clicked.
80 pub fn numbered(current: usize, total: usize, url: impl Fn(usize) -> String) -> Self {
81 if total <= 1 {
82 return Self { steps: Vec::new() };
83 }
84
85 let mut steps = Vec::new();
86
87 if current > 1 {
88 steps.push(Step::link("← Previous", url(current - 1)));
89 }
90
91 let mut last: Option<usize> = None;
92
93 for page in 1..=total {
94 let near = page.abs_diff(current) <= WINDOW;
95 let end = page == 1 || page == total;
96
97 if !near && !end {
98 continue;
99 }
100
101 if last.is_some_and(|last| page > last + 1) {
102 steps.push(Step::gap());
103 }
104
105 steps.push(match page == current {
106 true => Step::here(page.to_string()),
107 false => Step::link(page.to_string(), url(page)),
108 });
109
110 last = Some(page);
111 }
112
113 if current < total {
114 steps.push(Step::link("Next →", url(current + 1)));
115 }
116
117 Self { steps }
118 }
119
120 pub fn is_empty(&self) -> bool {
121 self.steps.is_empty()
122 }
123}
124
125/// The page a `?page=` asked for, unclamped.
126///
127/// Anything unreadable — an empty value, a word, a negative — is page 1. This is a link
128/// somebody may have mangled, and the range clamp is the use case's job anyway.
129pub(super) fn requested_page(value: Option<&str>) -> usize {
130 value
131 .and_then(|value| value.trim().parse::<usize>().ok())
132 .filter(|page| *page > 0)
133 .unwrap_or(1)
134}
135
136/// The sentence above the control: what this page is showing, out of what.
137///
138/// A range rather than a bare count, because "Showing 50 of 312 commits" says the same
139/// thing on every page and so says nothing on the fifth.
140/// The same sentence for a total that a walk stopped counting at.
141///
142/// `Showing 1–50 of 500+ commits`: the `+` is the whole point, because "500" and "at
143/// least 500" are different statements to somebody deciding whether they have seen a
144/// file's whole history. An uncapped total reads exactly as it always did.
145pub(super) fn showing_at_most(
146 offset: usize,
147 shown: usize,
148 total: usize,
149 capped: bool,
150 plural: &str,
151) -> String {
152 if !capped || shown == 0 {
153 return showing(offset, shown, total, plural);
154 }
155
156 format!(
157 "Showing {}–{} of {total}+ {plural}",
158 offset + 1,
159 offset + shown
160 )
161}
162
163pub(super) fn showing(offset: usize, shown: usize, total: usize, plural: &str) -> String {
164 if shown == 0 {
165 return format!("No {plural}.");
166 }
167
168 format!(
169 "Showing {}–{} of {total} {plural}",
170 offset + 1,
171 offset + shown
172 )
173}
174
175/// A list's sentence and its control, in that order, or neither of them.
176///
177/// Every paged list ends this way — "Showing 51–100 of 312 commits" above the numbers —
178/// and a list with one page says nothing at all, so the two travel together rather than
179/// each page remembering to suppress both.
180#[component]
181pub(super) async fn list_pages(summary: &str, pages: &Pages) -> Result {
182 view! {
183 if !pages.is_empty() {
184 <div class="mt-2 space-y-1">
185 <p class="text-xs text-muted-foreground">(summary)</p>
186 pager(pages: pages)
187 </div>
188 }
189 }
190}
191
192/// The paging line: one muted mono row at the foot of a list, right-aligned.
193///
194/// Links, not buttons — every page is a URL, and a control built from buttons cannot be
195/// opened in a new tab, bookmarked or crawled. The current page is the only thing in
196/// the foreground colour; nothing here is coloured, because paging is furniture and the
197/// list is the page.
198#[component]
199pub(super) async fn pager(pages: &Pages) -> Result {
200 view! {
201 if !pages.is_empty() {
202 <nav
203 aria-label="Pagination"
204 class="mt-2 flex flex-wrap items-center justify-end gap-x-2 gap-y-1 font-mono text-xs text-muted-foreground"
205 >
206 for step in &pages.steps {
207 match (step.href.as_deref(), step.current) {
208 (Some(href), _) => <a href=(href) class="hover:text-foreground">(step.label.as_str())</a>,
209 (None, true) => <span class="text-foreground" aria-current="page">(step.label.as_str())</span>,
210 (None, false) => <span aria-hidden="true">(step.label.as_str())</span>,
211 }
212 }
213 </nav>
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn labels(pages: &Pages) -> Vec<&str> {
223 pages.steps.iter().map(|step| step.label.as_str()).collect()
224 }
225
226 #[test]
227 fn one_page_renders_nothing() {
228 assert!(Pages::numbered(1, 1, |page| format!("?page={page}")).is_empty());
229 }
230
231 #[test]
232 fn the_first_page_has_no_previous() {
233 let pages = Pages::numbered(1, 3, |page| format!("?page={page}"));
234
235 assert_eq!(labels(&pages), ["1", "2", "3", "Next →"]);
236 }
237
238 #[test]
239 fn the_last_page_has_no_next() {
240 let pages = Pages::numbered(3, 3, |page| format!("?page={page}"));
241
242 assert_eq!(labels(&pages), ["← Previous", "1", "2", "3"]);
243 }
244
245 #[test]
246 fn the_current_page_is_not_a_link() {
247 let pages = Pages::numbered(2, 3, |page| format!("?page={page}"));
248 let current: Vec<_> = pages
249 .steps
250 .iter()
251 .filter(|step| step.current)
252 .map(|step| step.label.as_str())
253 .collect();
254
255 assert_eq!(current, ["2"]);
256 assert!(
257 pages
258 .steps
259 .iter()
260 .all(|step| !step.current || step.href.is_none())
261 );
262 }
263
264 #[test]
265 fn a_long_run_collapses_around_the_current_page() {
266 let pages = Pages::numbered(5, 12, |page| format!("?page={page}"));
267
268 assert_eq!(
269 labels(&pages),
270 ["← Previous", "1", "…", "4", "5", "6", "…", "12", "Next →"]
271 );
272 }
273
274 #[test]
275 fn a_gap_of_one_is_shown_rather_than_elided() {
276 // Page 3 of 6 windows to 2..4, so page 1 is adjacent and there is nothing to
277 // hide — an ellipsis standing in for a single number is longer than the number.
278 let pages = Pages::numbered(3, 6, |page| format!("?page={page}"));
279
280 assert_eq!(
281 labels(&pages),
282 ["← Previous", "1", "2", "3", "4", "…", "6", "Next →"]
283 );
284 }
285
286 #[test]
287 fn links_carry_whatever_the_caller_puts_in_the_url() {
288 let pages = Pages::numbered(1, 2, |page| format!("/issues?state=closed&page={page}"));
289 let next = pages.steps.last().expect("a next link");
290
291 assert_eq!(next.href.as_deref(), Some("/issues?state=closed&page=2"));
292 }
293
294 #[test]
295 fn an_unreadable_page_number_is_the_first_page() {
296 assert_eq!(requested_page(Some("400")), 400);
297 assert_eq!(requested_page(Some("0")), 1);
298 assert_eq!(requested_page(Some("banana")), 1);
299 assert_eq!(requested_page(Some("-2")), 1);
300 assert_eq!(requested_page(None), 1);
301 }
302
303 #[test]
304 fn the_sentence_names_the_range_and_the_whole() {
305 assert_eq!(
306 showing(50, 50, 312, "commits"),
307 "Showing 51–100 of 312 commits"
308 );
309 assert_eq!(showing(0, 3, 3, "issues"), "Showing 1–3 of 3 issues");
310 }
311
312 #[test]
313 fn a_capped_total_says_so_in_the_sentence() {
314 // "500" and "at least 500" are different statements about a file's history.
315 assert_eq!(
316 showing_at_most(0, 50, 500, true, "commits"),
317 "Showing 1–50 of 500+ commits"
318 );
319 assert_eq!(
320 showing_at_most(0, 50, 500, false, "commits"),
321 "Showing 1–50 of 500 commits"
322 );
323 }
324}