From 01c1b31636ddc3b782d45c8f625b1751a16e9e65 Mon Sep 17 00:00:00 2001 From: "N0\\A" Date: Wed, 9 Sep 2026 15:23:53 +0200 Subject: [PATCH] horizontal --- src/theme.rs | 240 +++++++++++++-------------------------------------- 1 file changed, 61 insertions(+), 179 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index 5255b21..4e92fc2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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 {