horizontal
This commit is contained in:
+61
-179
@@ -110,45 +110,54 @@ impl Layout {
|
|||||||
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
|
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
|
||||||
/// windows makes a tidy grid rather than one long row — the rule rofigrid
|
/// windows makes a tidy grid rather than one long row — the rule rofigrid
|
||||||
/// used — and the overlay hugs whatever is there.
|
/// used — and the overlay hugs whatever is there.
|
||||||
|
/// Lay out `n` tiles in a single row for a display of the given logical size.
|
||||||
|
///
|
||||||
|
/// The overlay and the window previews resize automatically to fit all `n`
|
||||||
|
/// windows side-by-side within the display bounds.
|
||||||
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
|
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
|
||||||
let n = n.max(0);
|
let n = n.max(0);
|
||||||
let (cap_cols, cap_rows) = (t.max_cols.max(1), t.max_rows.max(1));
|
let cols = n.max(1);
|
||||||
// The box may never exceed the display, whatever the config says.
|
|
||||||
let box_w = t.max_w.clamp(1, display.0.max(1));
|
let box_w = t.max_w.clamp(1, display.0.max(1));
|
||||||
let box_h = t.max_h.clamp(1, display.1.max(1));
|
let box_h = t.max_h.clamp(1, display.1.max(1));
|
||||||
|
let aspect = display.0 as f64 / (display.1.max(1) as f64);
|
||||||
|
|
||||||
|
let margin = t.margin.min(box_w / 10).max(2);
|
||||||
|
let avail_for_items = (box_w - 2 * margin).max(cols);
|
||||||
|
let pitch = avail_for_items / cols;
|
||||||
|
|
||||||
|
// Gap and padding adapt when many items crowd the available width:
|
||||||
|
let gap = (pitch / 6).min(t.gap).max(0);
|
||||||
|
let max_elem_w = (pitch - gap).max(1);
|
||||||
|
let pad = (max_elem_w / 8).min(t.pad).max(1);
|
||||||
|
|
||||||
let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
|
let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
|
||||||
|
let furniture_h = 2 * margin + 2 * pad + label_row;
|
||||||
|
let max_thumb_h = (box_h - furniture_h)
|
||||||
|
.min((display.1 as f64 * 0.35).round() as i32)
|
||||||
|
.max(1);
|
||||||
|
let ideal_tile_w = (max_thumb_h as f64 * aspect).round() as i32;
|
||||||
|
|
||||||
// Divide the box by the caps: what is left after the furniture is one
|
let max_fit_tile_w = (max_elem_w - 2 * pad).max(1);
|
||||||
// thumbnail.
|
let tile_w = ideal_tile_w.min(max_fit_tile_w).max(1);
|
||||||
let per_col = 2 * t.pad + t.gap;
|
let tile_h = ((tile_w as f64 / aspect).round() as i32).max(1);
|
||||||
let per_row = 2 * t.pad + label_row + t.gap;
|
|
||||||
let tile_w = ((box_w - 2 * t.margin + t.gap) / cap_cols - per_col).max(1);
|
|
||||||
let tile_h = ((box_h - 2 * t.margin + t.gap) / cap_rows - per_row).max(1);
|
|
||||||
let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad);
|
|
||||||
|
|
||||||
// Columns: the balanced rule, so a handful of windows makes a tidy grid
|
let elem_w = tile_w + 2 * pad;
|
||||||
// rather than one long row, capped by the config.
|
let elem_h = tile_h + label_row + 2 * pad;
|
||||||
let mut cols = (n as f64).sqrt() as i32;
|
let rows = 1;
|
||||||
if cols * cols < n {
|
let visible_rows = 1;
|
||||||
cols += 1;
|
|
||||||
}
|
|
||||||
cols = cols.clamp(1, cap_cols);
|
|
||||||
// i32::div_ceil is still unstable; only the unsigned one is not.
|
|
||||||
let rows = (n + cols - 1) / cols;
|
|
||||||
let visible_rows = cap_rows.clamp(1, rows.max(1));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
visible_rows,
|
visible_rows,
|
||||||
n,
|
n,
|
||||||
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
|
width: (cols * elem_w + (cols - 1) * gap + 2 * margin).min(box_w),
|
||||||
height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin,
|
height: (elem_h + 2 * margin).min(box_h),
|
||||||
elem_w,
|
elem_w,
|
||||||
elem_h,
|
elem_h,
|
||||||
margin: t.margin,
|
margin,
|
||||||
gap: t.gap,
|
gap,
|
||||||
pad: t.pad,
|
pad,
|
||||||
tile_h,
|
tile_h,
|
||||||
spacing: t.spacing,
|
spacing: t.spacing,
|
||||||
line_h: t.line_h,
|
line_h: t.line_h,
|
||||||
@@ -323,53 +332,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_thumbnail_is_the_box_divided_by_the_caps() {
|
fn previews_and_overlay_resize_automatically() {
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
let l = Layout::new(&t, 12, ROOMY);
|
|
||||||
let tile = l.tile(0, 0).expect("visible");
|
|
||||||
// Four columns of (tile + padding) plus three gaps plus two margins fill
|
|
||||||
// the box, give or take integer division.
|
|
||||||
let used = 4 * (tile.w + 2 * t.pad) + 3 * t.gap + 2 * t.margin;
|
|
||||||
assert!((1000 - used).abs() <= 4, "width {used} should fill 1000");
|
|
||||||
let label_row = t.spacing + t.line_h;
|
|
||||||
let used = 3 * (tile.h + label_row + 2 * t.pad) + 2 * t.gap + 2 * t.margin;
|
|
||||||
assert!((900 - used).abs() <= 4, "height {used} should fill 900");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_window_gets_the_same_thumbnail_as_thirty() {
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
let t = theme(1000, 900, 4, 3);
|
||||||
let one = Layout::new(&t, 1, ROOMY);
|
let one = Layout::new(&t, 1, ROOMY);
|
||||||
let many = Layout::new(&t, 30, ROOMY);
|
let two = Layout::new(&t, 2, ROOMY);
|
||||||
assert_eq!(
|
let eight = Layout::new(&t, 8, ROOMY);
|
||||||
one.tile(0, 0).expect("visible").w,
|
|
||||||
many.tile(0, 0).expect("visible").w,
|
assert_eq!((one.rows, one.visible_rows), (1, 1));
|
||||||
"thumbnail size must not depend on how many windows are open"
|
assert_eq!((two.rows, two.visible_rows), (1, 1));
|
||||||
);
|
assert_eq!((eight.rows, eight.visible_rows), (1, 1));
|
||||||
// The overlay hugs what is there: one tile is a small window.
|
|
||||||
assert_eq!((one.cols, one.rows), (1, 1));
|
assert_eq!(one.cols, 1);
|
||||||
|
assert_eq!(two.cols, 2);
|
||||||
|
assert_eq!(eight.cols, 8);
|
||||||
|
|
||||||
|
// Previews shrink automatically as more windows are added
|
||||||
assert!(
|
assert!(
|
||||||
one.width < many.width && one.height < many.height,
|
two.tile(0, 0).expect("visible").w >= eight.tile(0, 0).expect("visible").w,
|
||||||
"{one:?}"
|
"previews should scale down to fit"
|
||||||
);
|
);
|
||||||
assert!(!one.scrollable() && many.scrollable());
|
// Overlay width adjusts with the count
|
||||||
|
assert!(one.width <= two.width);
|
||||||
|
assert!(eight.width <= 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grids_stay_balanced_and_within_the_caps() {
|
fn single_row_holds_all_windows() {
|
||||||
let t = theme(1000, 900, 4, 3);
|
let t = theme(1000, 900, 4, 3);
|
||||||
// (n, cols, rows): ceil(sqrt(n)) columns, capped at four.
|
for n in 1..=10 {
|
||||||
for (n, cols, rows) in [
|
|
||||||
(1, 1, 1),
|
|
||||||
(2, 2, 1),
|
|
||||||
(4, 2, 2),
|
|
||||||
(6, 3, 2),
|
|
||||||
(12, 4, 3),
|
|
||||||
(30, 4, 8),
|
|
||||||
] {
|
|
||||||
let l = Layout::new(&t, n, ROOMY);
|
let l = Layout::new(&t, n, ROOMY);
|
||||||
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
|
assert_eq!((l.cols, l.rows, l.visible_rows), (n, 1, 1), "n = {n}");
|
||||||
assert!(l.visible_rows <= t.max_rows, "n = {n}");
|
assert!(!l.scrollable());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,29 +382,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn labels_take_their_room_from_the_thumbnail() {
|
fn labels_take_their_room_from_the_thumbnail() {
|
||||||
let mut t = theme(1000, 900, 4, 3);
|
let mut t = theme(1000, 900, 4, 3);
|
||||||
let with = Layout::new(&t, 12, ROOMY);
|
let with = Layout::new(&t, 4, ROOMY);
|
||||||
t.labels = false;
|
t.labels = false;
|
||||||
let without = Layout::new(&t, 12, ROOMY);
|
let without = Layout::new(&t, 4, ROOMY);
|
||||||
// The box is fixed, so dropping labels makes thumbnails taller rather
|
|
||||||
// than the window shorter.
|
|
||||||
assert!(
|
assert!(
|
||||||
without.tile(0, 0).expect("visible").h > with.tile(0, 0).expect("visible").h,
|
without.tile(0, 0).expect("visible").h >= with.tile(0, 0).expect("visible").h,
|
||||||
"thumbnails should grow into the freed row"
|
"thumbnails should grow into the freed space"
|
||||||
);
|
);
|
||||||
assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none());
|
assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none());
|
||||||
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
let l = Layout::new(&t, 4, ROOMY);
|
|
||||||
for i in 0..4 {
|
|
||||||
let (tile, label, elem) = (
|
|
||||||
l.tile(i, 0).expect("visible"),
|
|
||||||
l.label(i, 0).unwrap(),
|
|
||||||
l.elem(i, 0).expect("visible"),
|
|
||||||
);
|
|
||||||
assert_eq!(label.y, tile.y + tile.h + t.spacing);
|
|
||||||
assert_eq!(label.w, tile.w);
|
|
||||||
assert!(label.y + label.h + t.pad <= elem.y + elem.h);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -421,15 +399,13 @@ mod tests {
|
|||||||
let l = Layout::new(&t, 30, (640, 480));
|
let l = Layout::new(&t, 30, (640, 480));
|
||||||
assert!(l.width <= 640 && l.height <= 480, "{l:?}");
|
assert!(l.width <= 640 && l.height <= 480, "{l:?}");
|
||||||
assert!(l.tile(0, 0).expect("visible").w >= 1);
|
assert!(l.tile(0, 0).expect("visible").w >= 1);
|
||||||
assert!(l.scrollable());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_testing_is_the_inverse_of_the_layout() {
|
fn hit_testing_is_the_inverse_of_the_layout() {
|
||||||
let t = Theme::default();
|
let t = Theme::default();
|
||||||
// 7 tiles over 3 columns: the last row holds one, so two cells are empty.
|
let l = Layout::new(&t, 5, ROOMY);
|
||||||
let l = Layout::new(&t, 7, ROOMY);
|
for i in 0..5 {
|
||||||
for i in 0..7 {
|
|
||||||
let e = l.elem(i, 0).expect("visible");
|
let e = l.elem(i, 0).expect("visible");
|
||||||
for (x, y, what) in [
|
for (x, y, what) in [
|
||||||
(e.x, e.y, "top left"),
|
(e.x, e.y, "top left"),
|
||||||
@@ -439,8 +415,6 @@ mod tests {
|
|||||||
assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}");
|
assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The window margin, the gap between elements, and the empty cells of
|
|
||||||
// the last row all belong to no tile.
|
|
||||||
assert_eq!(l.hit(0, 0, 0), None, "margin");
|
assert_eq!(l.hit(0, 0, 0), None, "margin");
|
||||||
let first = l.elem(0, 0).expect("visible");
|
let first = l.elem(0, 0).expect("visible");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -448,101 +422,9 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
"gap between columns"
|
"gap between columns"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
l.hit(first.x, first.y + first.h + 1, 0),
|
|
||||||
None,
|
|
||||||
"gap between rows"
|
|
||||||
);
|
|
||||||
// Row 2, column 2 is past the seventh tile: take its column from the top
|
|
||||||
// row and its row from the first column.
|
|
||||||
let col2 = l.elem(2, 0).expect("visible");
|
|
||||||
let row2 = l.elem(6, 0).expect("visible");
|
|
||||||
assert_eq!(l.hit(col2.x + 4, row2.y + 4, 0), None, "empty cell");
|
|
||||||
assert_eq!(l.hit(-5, -5, 0), None, "outside");
|
assert_eq!(l.hit(-5, -5, 0), None, "outside");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rows_beyond_the_display_scroll_instead_of_shrinking() {
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
// Thirty tiles need more rows than the cap allows, so they scroll.
|
|
||||||
let l = Layout::new(&t, 30, ROOMY);
|
|
||||||
assert!(l.scrollable(), "{l:?} should scroll");
|
|
||||||
assert!(l.visible_rows < l.rows);
|
|
||||||
// The viewport shows a window of rows, and nothing outside it.
|
|
||||||
let per_screen = (l.visible_rows * l.cols) as usize;
|
|
||||||
assert!(l.elem(0, 0).is_some());
|
|
||||||
assert!(
|
|
||||||
l.elem(per_screen as i32, 0).is_none(),
|
|
||||||
"first row below the fold"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
l.elem(per_screen as i32, 1).is_some(),
|
|
||||||
"and visible once scrolled"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn max_rows_keeps_the_grid_compact() {
|
|
||||||
let mut t = theme(1000, 900, 4, 3);
|
|
||||||
let full = Layout::new(&t, 30, ROOMY);
|
|
||||||
t.max_rows = 2;
|
|
||||||
let capped = Layout::new(&t, 30, ROOMY);
|
|
||||||
assert!(
|
|
||||||
capped.visible_rows == 2 && full.visible_rows > 2,
|
|
||||||
"{capped:?}"
|
|
||||||
);
|
|
||||||
assert!(capped.height < full.height, "a shorter overlay");
|
|
||||||
assert!(capped.scrollable());
|
|
||||||
// The cap cannot invent rows: four tiles make a 2x2 grid, and a cap of
|
|
||||||
// five leaves it alone.
|
|
||||||
t.max_rows = 5;
|
|
||||||
let few = Layout::new(&t, 4, ROOMY);
|
|
||||||
assert_eq!((few.cols, few.rows, few.visible_rows), (2, 2, 2), "{few:?}");
|
|
||||||
assert!(!few.scrollable());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revealing_moves_the_viewport_as_little_as_possible() {
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
let l = Layout::new(&t, 30, ROOMY);
|
|
||||||
let last_visible = (l.visible_rows * l.cols - 1) as usize;
|
|
||||||
assert_eq!(l.reveal(0, 0), 0, "already on screen");
|
|
||||||
assert_eq!(l.reveal(last_visible, 0), 0, "still on screen");
|
|
||||||
// One row further down scrolls by exactly one row.
|
|
||||||
assert_eq!(l.reveal(last_visible + 1, 0), 1);
|
|
||||||
// Jumping to the end goes as far as it can, and no further.
|
|
||||||
assert_eq!(l.reveal(29, 0), l.max_scroll());
|
|
||||||
// Coming back up scrolls the other way.
|
|
||||||
assert_eq!(l.reveal(0, l.max_scroll()), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hit_testing_follows_the_scroll() {
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
let l = Layout::new(&t, 30, ROOMY);
|
|
||||||
let first = l.elem(0, 0).expect("visible");
|
|
||||||
let probe = (first.x + first.w / 2, first.y + first.h / 2);
|
|
||||||
assert_eq!(l.hit(probe.0, probe.1, 0), Some(0));
|
|
||||||
// The same pixel is a different tile once the grid has scrolled.
|
|
||||||
assert_eq!(l.hit(probe.0, probe.1, 1), Some(l.cols as usize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_scrollbar_appears_only_when_there_is_more_to_see() {
|
|
||||||
let t = theme(1000, 900, 4, 3);
|
|
||||||
assert!(Layout::new(&t, 4, ROOMY).scrollbar(0, 4).is_none());
|
|
||||||
let l = Layout::new(&t, 30, ROOMY);
|
|
||||||
let (track, top) = l.scrollbar(0, 4).expect("scrollable");
|
|
||||||
assert_eq!(top.y, track.y, "thumb starts at the top");
|
|
||||||
assert!(top.h < track.h, "thumb is shorter than its track");
|
|
||||||
let (_, bottom) = l.scrollbar(l.max_scroll(), 4).expect("scrollable");
|
|
||||||
assert_eq!(
|
|
||||||
bottom.y + bottom.h,
|
|
||||||
track.y + track.h,
|
|
||||||
"and ends at the bottom"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_preserves_aspect_and_centres() {
|
fn fit_preserves_aspect_and_centres() {
|
||||||
let box_ = Rect {
|
let box_ = Rect {
|
||||||
|
|||||||
Reference in New Issue
Block a user