horizontal

This commit is contained in:
2026-09-09 15:23:53 +02:00
parent 53cebf14f3
commit 01c1b31636
+61 -179
View File
@@ -110,45 +110,54 @@ impl Layout {
/// 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
/// 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 {
let n = n.max(0);
let (cap_cols, cap_rows) = (t.max_cols.max(1), t.max_rows.max(1));
// The box may never exceed the display, whatever the config says.
let cols = n.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 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 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
// thumbnail.
let per_col = 2 * t.pad + t.gap;
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);
let max_fit_tile_w = (max_elem_w - 2 * pad).max(1);
let tile_w = ideal_tile_w.min(max_fit_tile_w).max(1);
let tile_h = ((tile_w as f64 / aspect).round() as i32).max(1);
// Columns: the balanced rule, so a handful of windows makes a tidy grid
// rather than one long row, capped by the config.
let mut cols = (n as f64).sqrt() as i32;
if cols * cols < n {
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));
let elem_w = tile_w + 2 * pad;
let elem_h = tile_h + label_row + 2 * pad;
let rows = 1;
let visible_rows = 1;
Self {
cols,
rows,
visible_rows,
n,
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin,
width: (cols * elem_w + (cols - 1) * gap + 2 * margin).min(box_w),
height: (elem_h + 2 * margin).min(box_h),
elem_w,
elem_h,
margin: t.margin,
gap: t.gap,
pad: t.pad,
margin,
gap,
pad,
tile_h,
spacing: t.spacing,
line_h: t.line_h,
@@ -323,53 +332,37 @@ mod tests {
}
#[test]
fn a_thumbnail_is_the_box_divided_by_the_caps() {
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() {
fn previews_and_overlay_resize_automatically() {
let t = theme(1000, 900, 4, 3);
let one = Layout::new(&t, 1, ROOMY);
let many = Layout::new(&t, 30, ROOMY);
assert_eq!(
one.tile(0, 0).expect("visible").w,
many.tile(0, 0).expect("visible").w,
"thumbnail size must not depend on how many windows are open"
);
// The overlay hugs what is there: one tile is a small window.
assert_eq!((one.cols, one.rows), (1, 1));
let two = Layout::new(&t, 2, ROOMY);
let eight = Layout::new(&t, 8, ROOMY);
assert_eq!((one.rows, one.visible_rows), (1, 1));
assert_eq!((two.rows, two.visible_rows), (1, 1));
assert_eq!((eight.rows, eight.visible_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!(
one.width < many.width && one.height < many.height,
"{one:?}"
two.tile(0, 0).expect("visible").w >= eight.tile(0, 0).expect("visible").w,
"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]
fn grids_stay_balanced_and_within_the_caps() {
fn single_row_holds_all_windows() {
let t = theme(1000, 900, 4, 3);
// (n, cols, rows): ceil(sqrt(n)) columns, capped at four.
for (n, cols, rows) in [
(1, 1, 1),
(2, 2, 1),
(4, 2, 2),
(6, 3, 2),
(12, 4, 3),
(30, 4, 8),
] {
for n in 1..=10 {
let l = Layout::new(&t, n, ROOMY);
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
assert!(l.visible_rows <= t.max_rows, "n = {n}");
assert_eq!((l.cols, l.rows, l.visible_rows), (n, 1, 1), "n = {n}");
assert!(!l.scrollable());
}
}
@@ -389,29 +382,14 @@ mod tests {
#[test]
fn labels_take_their_room_from_the_thumbnail() {
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;
let without = Layout::new(&t, 12, ROOMY);
// The box is fixed, so dropping labels makes thumbnails taller rather
// than the window shorter.
let without = Layout::new(&t, 4, ROOMY);
assert!(
without.tile(0, 0).expect("visible").h > with.tile(0, 0).expect("visible").h,
"thumbnails should grow into the freed row"
without.tile(0, 0).expect("visible").h >= with.tile(0, 0).expect("visible").h,
"thumbnails should grow into the freed space"
);
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]
@@ -421,15 +399,13 @@ mod tests {
let l = Layout::new(&t, 30, (640, 480));
assert!(l.width <= 640 && l.height <= 480, "{l:?}");
assert!(l.tile(0, 0).expect("visible").w >= 1);
assert!(l.scrollable());
}
#[test]
fn hit_testing_is_the_inverse_of_the_layout() {
let t = Theme::default();
// 7 tiles over 3 columns: the last row holds one, so two cells are empty.
let l = Layout::new(&t, 7, ROOMY);
for i in 0..7 {
let l = Layout::new(&t, 5, ROOMY);
for i in 0..5 {
let e = l.elem(i, 0).expect("visible");
for (x, y, what) in [
(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}");
}
}
// 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");
let first = l.elem(0, 0).expect("visible");
assert_eq!(
@@ -448,101 +422,9 @@ mod tests {
None,
"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");
}
#[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]
fn fit_preserves_aspect_and_centres() {
let box_ = Rect {