use crate::geometry::{Rect, clamp_u16};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Sizing {
pub(super) min_width: u16,
pub(super) max_width: u16,
pub(super) height: u16,
pub(super) column_gap: u16,
pub(super) row_gap: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) struct Layout {
pub(super) body: Rect,
pub(super) columns: usize,
pub(super) card_width: u16,
pub(super) card_height: u16,
pub(super) column_gap: u16,
pub(super) row_gap: u16,
pub(super) count: usize,
pub(super) rows: usize,
pub(super) visible: usize,
pub(super) overflows: bool,
}
pub(super) fn columns(width: u16, sizing: Sizing) -> (usize, u16) {
let min = u32::from(sizing.min_width.max(1));
let max = sizing.max_width.max(sizing.min_width.max(1));
let gap = u32::from(sizing.column_gap);
let width32 = u32::from(width);
if width32 <= min {
return (1, width);
}
let columns = ((width32 + gap) / (min + gap)).max(1);
let card = (width32 - gap * (columns - 1)) / columns;
(usize::try_from(columns).unwrap_or(1), u16::try_from(card).unwrap_or(u16::MAX).min(max))
}
impl Layout {
pub(super) fn new(area: Rect, count: usize, sizing: Sizing) -> Self {
let pitch = u32::from(sizing.height.max(1)) + u32::from(sizing.row_gap);
let fits = (u32::from(area.height) + u32::from(sizing.row_gap)) / pitch;
let visible = usize::try_from(fits).unwrap_or(usize::MAX).max(1);
let place = |width: u16| {
let (columns, card_width) = columns(width, sizing);
(columns, card_width, count.div_ceil(columns))
};
let (mut columns, mut card_width, mut rows) = place(area.width);
let overflows = rows > visible;
let mut body = area;
if overflows {
body.width = area.width.saturating_sub(1);
(columns, card_width, rows) = place(body.width);
}
Self {
body,
columns,
card_width,
card_height: sizing.height.max(1),
column_gap: sizing.column_gap,
row_gap: sizing.row_gap,
count,
rows,
visible,
overflows,
}
}
fn row_pitch(&self) -> i64 {
i64::from(self.card_height) + i64::from(self.row_gap)
}
fn column_pitch(&self) -> i64 {
i64::from(self.card_width) + i64::from(self.column_gap)
}
pub(super) fn max_offset(&self) -> usize {
self.rows.saturating_sub(self.visible)
}
pub(super) fn cell_of(&self, index: usize) -> (usize, usize) {
(index / self.columns.max(1), index % self.columns.max(1))
}
pub(super) fn card_rect(&self, index: usize, offset: usize) -> Rect {
let (row, column) = self.cell_of(index);
let row = i64::try_from(row).unwrap_or(i64::MAX) - i64::try_from(offset).unwrap_or(0);
let column = i64::try_from(column).unwrap_or(0);
let x = i64::from(self.body.x) + column * self.column_pitch();
let y = i64::from(self.body.y) + row * self.row_pitch();
let clamp = |value: i64| i32::try_from(value).unwrap_or(if value < 0 { i32::MIN } else { i32::MAX });
Rect::new(clamp(x), clamp(y), self.card_width, self.card_height)
}
pub(super) fn shown(&self, offset: usize) -> std::ops::Range<usize> {
let pitch = u64::try_from(self.row_pitch()).unwrap_or(1).max(1);
let reach = u64::from(self.body.height).div_ceil(pitch);
let rows = usize::try_from(reach).unwrap_or(usize::MAX);
let start = offset.saturating_mul(self.columns).min(self.count);
let end = offset.saturating_add(rows).saturating_mul(self.columns).min(self.count);
start..end
}
pub(super) fn index_at(&self, x: i32, y: i32, offset: usize) -> Option<usize> {
if !self.body.contains(x, y) {
return None;
}
let dx = i64::from(x) - i64::from(self.body.x);
let dy = i64::from(y) - i64::from(self.body.y);
let (column_pitch, row_pitch) = (self.column_pitch().max(1), self.row_pitch().max(1));
if dx % column_pitch >= i64::from(self.card_width) || dy % row_pitch >= i64::from(self.card_height) {
return None;
}
let column = usize::try_from(dx / column_pitch).ok()?;
let row = offset.checked_add(usize::try_from(dy / row_pitch).ok()?)?;
let index = row.checked_mul(self.columns)?.checked_add(column)?;
(column < self.columns && index < self.count).then_some(index)
}
pub(super) fn step(&self, step: Step, from: Option<usize>) -> Option<usize> {
let last = self.count.checked_sub(1)?;
let columns = self.columns.max(1);
let Some(from) = from.map(|index| index.min(last)) else {
return Some(if matches!(step, Step::Up | Step::PageUp | Step::End) { last } else { 0 });
};
let (row, column) = self.cell_of(from);
let last_row = last / columns;
let page = self.visible.max(1);
let in_row = |row: usize| (row * columns + column).min(last);
Some(match step {
Step::Left => from - usize::from(column > 0),
Step::Right if column + 1 < columns => (from + 1).min(last),
Step::Right => from,
Step::Up => in_row(row.saturating_sub(1)),
Step::Down => in_row((row + 1).min(last_row)),
Step::PageUp => in_row(row.saturating_sub(page)),
Step::PageDown => in_row((row + page).min(last_row)),
Step::Home => 0,
Step::End => last,
})
}
pub(super) fn metrics(&self, offset: usize) -> super::super::scrollbar::ScrollMetrics {
super::super::scrollbar::ScrollMetrics { total: self.rows, visible: self.visible, offset }
}
pub(super) fn bar(&self) -> Option<Rect> {
self.overflows.then(|| Rect::new(self.body.right(), self.body.y, 1, self.body.height))
}
pub(super) fn reveal(&self, index: usize, offset: usize) -> usize {
let (row, _) = self.cell_of(index);
if row < offset {
row
} else if row >= offset + self.visible {
row + 1 - self.visible
} else {
offset
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Step {
Left,
Right,
Up,
Down,
PageUp,
PageDown,
Home,
End,
}
pub(super) fn height_of(rows: usize, sizing: Sizing) -> u16 {
let rows = i64::try_from(rows).unwrap_or(i64::MAX);
let total = rows
.saturating_mul(i64::from(sizing.height.max(1)) + i64::from(sizing.row_gap))
.saturating_sub(i64::from(sizing.row_gap));
clamp_u16(i32::try_from(total).unwrap_or(i32::MAX))
}
#[cfg(test)]
mod tests {
use super::*;
const APPS: Sizing = Sizing { min_width: 24, max_width: 32, height: 3, column_gap: 2, row_gap: 1 };
fn grid(width: u16, height: u16, count: usize) -> Layout {
Layout::new(Rect::new(0, 0, width, height), count, APPS)
}
#[test]
fn columns_follow_the_width() {
assert_eq!(columns(96, APPS), (3, 30));
assert_eq!(columns(140, APPS), (5, 26));
assert_eq!(columns(76, APPS), (3, 24), "three cards of the least width and two gaps fill 76 cells");
assert_eq!(columns(75, APPS), (2, 32), "one cell less leaves two cards, at most 32 wide");
assert_eq!(columns(200, APPS), (7, 26));
}
#[test]
fn a_narrow_area_is_one_column_as_wide_as_the_area() {
assert_eq!(columns(30, APPS), (1, 30), "wider than the least but not two cards: one card");
assert_eq!(columns(24, APPS), (1, 24));
assert_eq!(columns(12, APPS), (1, 12), "narrower than a card: the card fills the area");
assert_eq!(columns(0, APPS), (1, 0));
}
#[test]
fn the_scrollbar_takes_a_column_only_when_rows_overflow() {
let fits = grid(96, 20, 12);
assert_eq!((fits.rows, fits.visible, fits.overflows, fits.body.width), (4, 5, false, 96));
let scrolls = grid(96, 20, 100);
assert!(scrolls.overflows);
assert_eq!(scrolls.body.width, 95);
assert_eq!(scrolls.bar(), Some(Rect::new(95, 0, 1, 20)));
}
#[test]
fn keys_move_in_two_dimensions_and_stop_at_the_edges() {
let layout = grid(96, 30, 8);
assert_eq!(layout.step(Step::Right, Some(0)), Some(1));
assert_eq!(layout.step(Step::Right, Some(2)), Some(2), "the end of a row does not wrap");
assert_eq!(layout.step(Step::Left, Some(3)), Some(3), "nor does its start");
assert_eq!(layout.step(Step::Right, Some(7)), Some(7), "the last card stops");
assert_eq!(layout.step(Step::Down, Some(1)), Some(4));
assert_eq!(layout.step(Step::Down, Some(5)), Some(7), "a short last row gives its last card");
assert_eq!(layout.step(Step::Down, Some(7)), Some(7));
assert_eq!(layout.step(Step::Up, Some(7)), Some(4));
assert_eq!(layout.step(Step::Up, Some(1)), Some(1));
assert_eq!(layout.step(Step::Home, Some(5)), Some(0));
assert_eq!(layout.step(Step::End, Some(0)), Some(7));
assert_eq!(layout.step(Step::Down, None), Some(0));
assert_eq!(layout.step(Step::Up, None), Some(7));
assert_eq!(grid(96, 30, 0).step(Step::Down, None), None);
}
#[test]
fn pages_move_by_the_rows_that_fit() {
let layout = grid(96, 12, 30);
assert_eq!(layout.visible, 3);
assert_eq!(layout.step(Step::PageDown, Some(1)), Some(10));
assert_eq!(layout.step(Step::PageDown, Some(28)), Some(28));
assert_eq!(layout.step(Step::PageUp, Some(10)), Some(1));
assert_eq!(layout.step(Step::PageUp, Some(4)), Some(1));
}
#[test]
fn cells_map_to_cards_and_gaps_to_none() {
let layout = grid(96, 20, 8);
assert_eq!(layout.index_at(0, 0, 0), Some(0));
assert_eq!(layout.index_at(29, 2, 0), Some(0));
assert_eq!(layout.index_at(30, 0, 0), None, "the gap between columns");
assert_eq!(layout.index_at(32, 0, 0), Some(1));
assert_eq!(layout.index_at(0, 3, 0), None, "the gap between rows");
assert_eq!(layout.index_at(0, 4, 0), Some(3));
assert_eq!(layout.index_at(70, 4, 1), None, "past the last card");
assert_eq!(layout.index_at(0, 0, 1), Some(3), "scrolled by a row");
}
#[test]
fn revealing_scrolls_the_least() {
let layout = grid(96, 12, 60);
assert_eq!(layout.reveal(0, 0), 0);
assert_eq!(layout.reveal(9, 0), 1, "row 3 comes in at the bottom");
assert_eq!(layout.reveal(3, 5), 1, "row 1 comes in at the top");
assert_eq!(layout.reveal(20, 5), 5, "a card on screen does not scroll");
}
#[test]
fn shown_covers_whole_rows_and_the_top_of_the_next() {
let layout = grid(96, 10, 60);
assert_eq!(layout.visible, 2);
assert_eq!(layout.shown(0), 0..9, "two whole rows and the top two rows of the third");
assert_eq!(layout.shown(19), 57..60);
}
#[test]
fn heights_add_gaps_between_rows_only() {
assert_eq!(height_of(3, APPS), 11);
assert_eq!(height_of(0, APPS), 0);
assert_eq!(height_of(usize::MAX, APPS), u16::MAX);
}
}