Skip to main content

max / makeover-layout

Add Window and Paging: one mechanism, two intents A carousel is a window of one frame over children that are all present; a paged list is a window of a page over rows most of which were never fetched. Those are different facts and keep different types at the top level, so a call site says which it means. The arithmetic underneath is one piece of code, which is what stops a terminal and a browser disagreeing about which frame is last. Paging carries no addresses. makeover-layout cannot name an action, and the way to ask for the next part is the host's; that split is the reason the type is reusable by a carousel, which has nothing to ask. of is optional because a host may not be able to count, and unset means it never will: a total arriving on a later pass widens whatever prints it. Every derivation clamps rather than refusing, and a zero count answers None rather than dividing. Additive only, so a patch: nothing existing changed and no consumer moves.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 19:54 UTC
Signed with PGP, not checked
Commit: e0ae3a77d7c8c1b0615447239194c65bdcb4561d
Parent: 5d842b8
2 files changed, +340 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.27.1"
3 + version = "0.27.2"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +339
@@ -2327,6 +2327,246 @@
2327 2327 }
2328 2328 }
2329 2329
2330 + /// A window onto a sequence: where it starts, how much it covers, and how long
2331 + /// the sequence is when that is known.
2332 + ///
2333 + /// The mechanism under two things the vocabulary deliberately keeps apart. A
2334 + /// carousel is a window of one frame over children that are all present; a
2335 + /// paged list is a window of a page over rows most of which were never fetched.
2336 + /// Those are different facts and they stay different types — [`Showing`] says
2337 + /// which child is up, [`Paging`] says where a reader is in a query — but the
2338 + /// arithmetic underneath is one piece of code, so a terminal and a browser
2339 + /// cannot come to disagree about which frame is last.
2340 + ///
2341 + /// # Why `of` is optional and `count` is not
2342 + ///
2343 + /// `count` is what is on screen and is therefore always known. `of` is the
2344 + /// length of the thing being windowed, and a host that cannot count says so by
2345 + /// leaving it empty **for the life of the screen**. It is never "not counted
2346 + /// yet": see "First paint is final paint" in the crate header. A total that
2347 + /// turns up on a later pass widens the text that prints it.
2348 + ///
2349 + /// # Clamping
2350 + ///
2351 + /// Every derivation clamps rather than refusing, and a zero `count` answers
2352 + /// `None` rather than dividing. A window past the end is a bug in the host, and
2353 + /// a renderer that answered it by drawing nothing would report a region that
2354 + /// vanished, which is the hardest kind of bug to find from what is on screen.
2355 + /// [`Share::percent`] clamps for the same reason.
2356 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2357 + pub struct Window {
2358 + /// The index into the sequence where the window starts.
2359 + pub from: usize,
2360 + /// How many the window covers. One, for a carousel.
2361 + pub count: usize,
2362 + /// How long the sequence is, when the host can say.
2363 + pub of: Option<usize>,
2364 + }
2365 +
2366 + impl Window {
2367 + /// A window of `count`, starting at `from`, over a sequence of unknown
2368 + /// length.
2369 + #[must_use]
2370 + pub const fn new(from: usize, count: usize) -> Self {
2371 + Self {
2372 + from,
2373 + count,
2374 + of: None,
2375 + }
2376 + }
2377 +
2378 + /// How long the sequence is.
2379 + #[must_use]
2380 + pub const fn of(mut self, of: usize) -> Self {
2381 + self.of = Some(of);
2382 + self
2383 + }
2384 +
2385 + /// One item of a sequence whose length is known. A carousel frame.
2386 + #[must_use]
2387 + pub const fn frame(at: usize, of: usize) -> Self {
2388 + Self {
2389 + from: at,
2390 + count: 1,
2391 + of: Some(of),
2392 + }
2393 + }
2394 +
2395 + /// Which window this is, counting from zero.
2396 + ///
2397 + /// `None` when `count` is zero, which is the only input with no answer
2398 + /// rather than a clamped one.
2399 + #[must_use]
2400 + pub const fn index(self) -> Option<usize> {
2401 + if self.count == 0 {
2402 + return None;
2403 + }
2404 + Some(self.from / self.count)
2405 + }
2406 +
2407 + /// How many windows the sequence holds.
2408 + ///
2409 + /// `None` unless both the length and a non-zero `count` are known. A
2410 + /// partial answer here would be a renderer drawing "of 0".
2411 + #[must_use]
2412 + pub const fn windows(self) -> Option<usize> {
2413 + match self.of {
2414 + Some(of) if self.count > 0 => Some(of.div_ceil(self.count)),
2415 + _ => None,
2416 + }
2417 + }
2418 +
2419 + /// Whether anything sits before this window.
2420 + #[must_use]
2421 + pub const fn has_before(self) -> bool {
2422 + self.from > 0
2423 + }
2424 +
2425 + /// Whether anything sits after it.
2426 + ///
2427 + /// `true` when the length is unknown: a host that cannot count cannot rule
2428 + /// out more, and offering a way forward that turns out to be empty is the
2429 + /// cheaper of the two mistakes.
2430 + #[must_use]
2431 + pub const fn has_after(self) -> bool {
2432 + match self.of {
2433 + Some(of) => self.from.saturating_add(self.count) < of,
2434 + None => true,
2435 + }
2436 + }
2437 +
2438 + /// The window with `from` brought inside the sequence.
2439 + ///
2440 + /// A no-op when the length is unknown, since there is nothing to clamp
2441 + /// against.
2442 + #[must_use]
2443 + pub const fn clamped(mut self) -> Self {
2444 + if let Some(of) = self.of
2445 + && self.from >= of
2446 + {
2447 + // `max(1)` by hand: `Ord::max` is not const yet, and a zero-count
2448 + // window would otherwise clamp onto the end rather than inside it.
2449 + let step = if self.count == 0 { 1 } else { self.count };
2450 + self.from = of.saturating_sub(step);
2451 + }
2452 + self
2453 + }
2454 + }
2455 +
2456 + /// Where a reader is in a set that arrived in parts.
2457 + ///
2458 + /// A [`Window`] wearing the paged reading of itself. Distinct from a carousel's
2459 + /// window at the top level on purpose, because the intent differs and a call
2460 + /// site should say which one it means, while the arithmetic below is shared so
2461 + /// the two cannot drift apart.
2462 + ///
2463 + /// # The two idioms, and which one a renderer may draw
2464 + ///
2465 + /// Load-more and numbered pages are both this type. Which is honest is
2466 + /// [`paged`](Self::paged): a set whose page size is known can be drawn as
2467 + /// "Page 3 of 8", and one without can only be drawn as "150 of 400" and a way
2468 + /// forward. Saying it here rather than letting each renderer guess is the point
2469 + /// — three renderers inferring it from the numbers is how they come to disagree.
2470 + ///
2471 + /// # What it does not carry
2472 + ///
2473 + /// No addresses. `makeover-layout` cannot name an action, and the way to ask for
2474 + /// the next part is the host's: `quasi_router` pairs this with the addresses the
2475 + /// same way `Row` pairs its parts with `Row::activate`. That split is the reason
2476 + /// this type is reusable by a carousel, which has nothing to ask.
2477 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2478 + pub struct Paging {
2479 + /// The window onto the set.
2480 + pub window: Window,
2481 + /// Whether the parts are a fixed size, and so whether pages are countable.
2482 + ///
2483 + /// `false` for load-more, where the window simply grew and "page 2" would
2484 + /// name nothing.
2485 + pub paged: bool,
2486 + }
2487 +
2488 + impl Paging {
2489 + /// A page of `per`, starting at `from`.
2490 + #[must_use]
2491 + pub const fn pages(from: usize, per: usize) -> Self {
2492 + Self {
2493 + window: Window::new(from, per),
2494 + paged: true,
2495 + }
2496 + }
2497 +
2498 + /// The first `shown`, with more behind them.
2499 + ///
2500 + /// The load-more shape: the window starts at the beginning and grows, so
2501 + /// there is no page to number.
2502 + #[must_use]
2503 + pub const fn more(shown: usize) -> Self {
2504 + Self {
2505 + window: Window::new(0, shown),
2506 + paged: false,
2507 + }
2508 + }
2509 +
2510 + /// How many there are altogether.
2511 + ///
2512 + /// Left unsaid by a host that cannot count, and left unsaid **for good**:
2513 + /// a total arriving later widens whatever prints it. See "First paint is
2514 + /// final paint" in the crate header.
2515 + #[must_use]
2516 + pub const fn of(mut self, of: usize) -> Self {
2517 + self.window = self.window.of(of);
2518 + self
2519 + }
2520 +
2521 + /// Which page this is, counting from one, when pages are countable.
2522 + ///
2523 + /// One-based because it is read aloud. [`Window::index`] is the zero-based
2524 + /// form for anyone indexing with it.
2525 + #[must_use]
2526 + pub const fn page(self) -> Option<usize> {
2527 + if !self.paged {
2528 + return None;
2529 + }
2530 + match self.window.index() {
2531 + Some(index) => Some(index + 1),
2532 + None => None,
2533 + }
2534 + }
2535 +
2536 + /// How many pages there are, when that is countable.
2537 + #[must_use]
2538 + pub const fn pages_total(self) -> Option<usize> {
2539 + if !self.paged {
2540 + return None;
2541 + }
2542 + self.window.windows()
2543 + }
2544 +
2545 + /// How many are on screen.
2546 + #[must_use]
2547 + pub const fn shown(self) -> usize {
2548 + self.window.count
2549 + }
2550 +
2551 + /// How many there are, when the host counted.
2552 + #[must_use]
2553 + pub const fn total(self) -> Option<usize> {
2554 + self.window.of
2555 + }
2556 +
2557 + /// Whether there is anything further on.
2558 + #[must_use]
2559 + pub const fn has_more(self) -> bool {
2560 + self.window.has_after()
2561 + }
2562 +
2563 + /// Whether there is anything back the other way.
2564 + #[must_use]
2565 + pub const fn has_previous(self) -> bool {
2566 + self.window.has_before()
2567 + }
2568 + }
2569 +
2330 2570 /// How much of the width an arrangement's first region takes.
2331 2571 ///
2332 2572 /// `e0fd485e`. Nothing said how much room a region got, so every renderer
@@ -4300,4 +4540,103 @@
4300 4540 // the deferral rule has been broken.
4301 4541 assert_ne!(Readiness::Ready, Readiness::Pending);
4302 4542 }
4543 +
4544 + #[test]
4545 + fn a_window_with_no_length_still_answers_what_it_can() {
4546 + // The uncounted case is the common one, not the degenerate one: a query
4547 + // that asked for 51 to learn there were more than 50 knows there are,
4548 + // and not how many.
4549 + let uncounted = Window::new(100, 50);
4550 + assert_eq!(uncounted.index(), Some(2));
4551 + assert_eq!(uncounted.windows(), None);
4552 + assert!(uncounted.has_before());
4553 + // Unknown length cannot rule out more, and offering a way forward that
4554 + // turns out empty is the cheaper mistake.
4555 + assert!(uncounted.has_after());
4556 + }
4557 +
4558 + #[test]
4559 + fn a_counted_window_knows_where_it_ends() {
4560 + let last = Window::new(350, 50).of(400);
4561 + assert_eq!(last.index(), Some(7));
4562 + assert_eq!(last.windows(), Some(8));
4563 + assert!(last.has_before());
4564 + assert!(!last.has_after());
4565 +
4566 + let first = Window::new(0, 50).of(400);
4567 + assert!(!first.has_before());
4568 + assert!(first.has_after());
4569 + }
4570 +
4571 + #[test]
4572 + fn a_window_that_does_not_divide_evenly_rounds_up() {
4573 + // 401 rows in pages of 50 is eight pages and a straggler, which is nine
4574 + // pages. Rounding down would make the last one unreachable.
4575 + assert_eq!(Window::new(0, 50).of(401).windows(), Some(9));
4576 + }
4577 +
4578 + #[test]
4579 + fn a_zero_count_answers_none_rather_than_dividing() {
4580 + let empty = Window::new(0, 0).of(400);
4581 + assert_eq!(empty.index(), None);
4582 + assert_eq!(empty.windows(), None);
4583 + // And it still clamps rather than panicking.
4584 + assert_eq!(Window::new(900, 0).of(400).clamped().from, 399);
4585 + }
4586 +
4587 + #[test]
4588 + fn a_window_past_the_end_clamps_inside_rather_than_vanishing() {
4589 + // `Slot::current`'s reasoning, one layer down: a description pointing
4590 + // past the end is a host bug, and answering it by drawing nothing
4591 + // reports a region that vanished.
4592 + assert_eq!(Window::new(900, 50).of(400).clamped().from, 350);
4593 + // Nothing to clamp against when the length is unknown.
4594 + assert_eq!(Window::new(900, 50).clamped().from, 900);
4595 + }
4596 +
4597 + #[test]
4598 + fn a_carousel_frame_is_a_window_of_one() {
4599 + // The shape a carousel instantiates. Same code as a paged list, which is
4600 + // the whole reason `Window` exists rather than two copies of it.
4601 + let third = Window::frame(2, 5);
4602 + assert_eq!(third.index(), Some(2));
4603 + assert_eq!(third.windows(), Some(5));
4604 + assert!(third.has_before());
4605 + assert!(third.has_after());
4606 +
4607 + let last = Window::frame(4, 5);
4608 + assert!(!last.has_after());
4609 + }
4610 +
4611 + #[test]
4612 + fn numbered_pages_read_from_one_and_load_more_has_no_page() {
4613 + // The page number is read aloud, so it is one-based; `Window::index` is
4614 + // the zero-based form for indexing.
4615 + let third = Paging::pages(100, 50).of(400);
4616 + assert_eq!(third.page(), Some(3));
4617 + assert_eq!(third.pages_total(), Some(8));
4618 + assert_eq!(third.total(), Some(400));
4619 + assert!(third.has_previous());
4620 + assert!(third.has_more());
4621 +
4622 + // Load-more grew a window from the start, so "page 2" would name
4623 + // nothing and the type says so rather than inventing one.
4624 + let grown = Paging::more(150).of(400);
4625 + assert_eq!(grown.page(), None);
4626 + assert_eq!(grown.pages_total(), None);
4627 + assert_eq!(grown.shown(), 150);
4628 + assert!(!grown.has_previous());
4629 + assert!(grown.has_more());
4630 + }
4631 +
4632 + #[test]
4633 + fn an_uncounted_paging_offers_forward_and_admits_no_total() {
4634 + // What a host that will not pay for a COUNT describes. `None` here is
4635 + // permanent: a total arriving later would widen the text that prints it,
4636 + // which is the reflow "first paint is final paint" forbids.
4637 + let feed = Paging::more(50);
4638 + assert_eq!(feed.total(), None);
4639 + assert_eq!(feed.pages_total(), None);
4640 + assert!(feed.has_more());
4641 + }
4303 4642 }