diff --git a/README.md b/README.md index e2bd5c7..8bab87a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ chooser_cmd=wl-pick --format portal |---|---| | `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile | | `↓` `↑` / `j` `k` | move a row | -| `Home` `End` | first / last | +| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time | | `Enter` | pick the selection | | `Escape` / `q` | cancel | | click | pick that tile | @@ -170,12 +170,18 @@ time it runs. On a mixed setup one file gives 18% of a 1280-wide laptop panel an wrong on the other. The overlay is mapped explicitly on that display, at that display's scale, so mixed-DPI renders crisply either way. -`tile-width` and `tile-height` are maxima for the thumbnail cell. Give only the -width and the height follows the display's aspect, which is roughly the shape of -the windows on it — a 16:9 cell wastes about half its area on a portrait monitor. -If the grid would outgrow the display, tiles shrink together and keep their -shape, so thirty windows give small tiles rather than a surface larger than the -screen. +`tile-width` and `tile-height` set how big a thumbnail actually is. Give only +the width and the height follows the display's aspect, which is roughly the shape +of the windows on it — a 16:9 cell wastes about half its area on a portrait +monitor. + +When there are more rows than the display can show, **the grid scrolls**: the +tile size you asked for is honoured and a scrollbar appears in the right margin. +Any move keeps the selection in view, `PgUp`/`PgDn` jump a screen, and tiles +scrolled out of sight are unmapped — so live capture skips them too, which is +what stops a long list costing bandwidth for pixels nobody sees. Only a tile too +large for even one row or column is shrunk, since then nothing could be shown at +all. ## Look diff --git a/src/app.rs b/src/app.rs index 52b6399..92d2ae3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -88,6 +88,10 @@ pub struct App { pub(crate) fps: u32, pub(crate) scale: i32, pub(crate) sel: usize, + /// First row of the grid on screen. The rest scroll. + pub(crate) scroll: i32, + /// Set when the viewport moved and the subsurfaces need re-placing. + pub(crate) needs_tiles: bool, pub(crate) shift: bool, /// Where the pointer is, and which tile it pressed. Hovering deliberately @@ -185,6 +189,8 @@ impl App { fps, scale, sel: 0, + scroll: 0, + needs_tiles: false, shift: false, hover: None, pressed: None, @@ -243,6 +249,12 @@ impl App { self.stats.pool_bytes >> 20, self.labels.as_ref().map(|l| l.family()).unwrap_or("none"), ); + if self.layout.scrollable() { + eprintln!( + "wl-pick: {} of {} rows fit; the rest scroll", + self.layout.visible_rows, self.layout.rows + ); + } } /// What live capture actually did, once the overlay is closing. diff --git a/src/capture.rs b/src/capture.rs index 8c5b9dc..5249dcd 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -328,6 +328,11 @@ impl App { if self.tiles[i].target.kind == Kind::Output { continue; } + // Nor is there any point refreshing a tile that is scrolled out of + // sight — that is a readback for pixels nobody sees. + if self.layout.tile(i as i32, self.scroll).is_none() { + continue; + } let t = &self.tiles[i]; if t.slots.is_empty() || t.frame.is_some() { continue; diff --git a/src/cli.rs b/src/cli.rs index 5ddc38e..3596fd5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,8 +29,8 @@ usage: wl-pick [options] -v, --verbose phase timings, tile list and capture stats -h, --help this -keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks; - Escape or q cancels +keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump; + Enter picks; Escape or q cancels mouse: click a tile to pick it, scroll to move. Hovering does not move the selection, and a click outside a tile does nothing. @@ -51,9 +51,9 @@ config: border = #d79921 border-width = 2px - tile-width = 18ppt # largest a thumbnail may be + tile-width = 18ppt # how big a thumbnail is tile-height = 20ppt # defaults to the display's aspect - max-columns = 4 + max-columns = 4 # rows beyond the screen scroll font = monospace # also --font font-size = 13.3 diff --git a/src/main.rs b/src/main.rs index ebbe2e5..e8cee1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,7 +101,7 @@ fn run() -> Result> { theme.font.clone(), theme.font_px * scale as f32, (theme.line_h * scale) as f32, - (layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w) * scale) as f32, + (layout.label(0, 0).map(|r| r.w).unwrap_or(theme.tile_w) * scale) as f32, ) }); @@ -134,12 +134,20 @@ fn run() -> Result> { app.show(&qh)?; pump(&mut queue, &mut app, |a| a.configured)?; app.paint(); - app.place_tiles(&qh); + app.sync_tiles(&qh); app.arm_frame_callback(&qh); conn.flush()?; phases.mark("mapped"); - pump(&mut queue, &mut app, |a| a.finished())?; + // Scrolling re-places the subsurfaces; doing it here rather than inside the + // key handler coalesces a held-down arrow into one update per dispatch. + while !app.finished() { + queue.blocking_dispatch(&mut app)?; + if std::mem::take(&mut app.needs_tiles) { + app.sync_tiles(&qh); + conn.flush()?; + } + } if opts.verbose { app.report(start.elapsed()); } diff --git a/src/overlay.rs b/src/overlay.rs index 164b2bb..bed0a5a 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -47,6 +47,8 @@ const KEY_LEFT: u32 = 105; const KEY_RIGHT: u32 = 106; const KEY_END: u32 = 107; const KEY_DOWN: u32 = 108; +const KEY_PGUP: u32 = 104; +const KEY_PGDN: u32 = 109; /// evdev button code, as wl_pointer reports it. const BTN_LEFT: u32 = 0x110; @@ -107,11 +109,20 @@ impl App { Ok(()) } - /// Attach each captured buffer to its own subsurface and let the compositor - /// scale it into the tile rectangle. - pub fn place_tiles(&mut self, qh: &QueueHandle) { + /// Put every visible tile where the viewport says, and unmap the rest. + /// + /// Runs again after each scroll, so a tile scrolled off screen gets a null + /// buffer — the way to hide a subsurface — rather than being left behind. + /// Scaling stays the compositor's job: the capture buffer is attached as it + /// is, and wp_viewporter names the rectangle to fit it into. + pub fn sync_tiles(&mut self, qh: &QueueHandle) { let parent = self.surface.clone().expect("show() runs first"); + let scroll = self.scroll; for i in 0..self.tiles.len() { + let Some(box_) = self.layout.tile(i as i32, scroll) else { + self.hide_tile(i); + continue; + }; if !self.tiles[i].ready { continue; } @@ -121,42 +132,69 @@ impl App { } else { (bw as i32, bh as i32) }; - let dst = fit_centred(fit_w, fit_h, self.layout.tile(i as i32)); - let surface = self.compositor.create_surface(qh, ()); - let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ()); - let viewport = self.viewporter.get_viewport(&surface, qh, ()); + let dst = fit_centred(fit_w, fit_h, box_); + if self.tiles[i].surface.is_none() { + let surface = self.compositor.create_surface(qh, ()); + let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ()); + let viewport = self.viewporter.get_viewport(&surface, qh, ()); + // Tiles change independently of the chrome — a live frame + // arrives whenever its window does — so they must not wait on a + // parent commit. + subsurface.set_desync(); + // The capture protocol reports the transform the compositor + // already applied to the buffer, which is exactly what this + // request means, so it passes straight through. + surface.set_buffer_transform(self.tiles[i].transform); + let t = &mut self.tiles[i]; + t.surface = Some(surface); + t.subsurface = Some(subsurface); + t.viewport = Some(viewport); + } + let t = &self.tiles[i]; + let (surface, subsurface, viewport) = ( + t.surface.clone().expect("just created"), + t.subsurface.clone().expect("just created"), + t.viewport.clone().expect("just created"), + ); + let slot = t.showing.expect("a ready tile has a slot"); subsurface.set_position(dst.x, dst.y); - // Tiles change independently of the chrome — a live frame arrives - // whenever its window does — so they must not wait on a parent - // commit. - subsurface.set_desync(); - // The capture protocol reports the transform the compositor already - // applied to the buffer, which is exactly what this request means, - // so it passes straight through and the compositor un-rotates it. - surface.set_buffer_transform(self.tiles[i].transform); viewport.set_destination(dst.w, dst.h); - let slot = self.tiles[i].showing.expect("a ready tile has a slot"); - surface.attach(Some(&self.tiles[i].slots[slot].buffer), 0, 0); + surface.attach(Some(&t.slots[slot].buffer), 0, 0); surface.damage_buffer(0, 0, bw as i32, bh as i32); surface.commit(); - let t = &mut self.tiles[i]; - t.surface = Some(surface); - t.subsurface = Some(subsurface); - t.viewport = Some(viewport); } // Subsurface placement is *parent* state: it only takes effect when the // parent commits, desynced children included. parent.commit(); } + /// Unmap a tile's subsurface by attaching nothing to it. + fn hide_tile(&mut self, i: usize) { + if let Some(surface) = self.tiles[i].surface.clone() { + surface.attach(None, 0, 0); + surface.commit(); + } + } + /// Repaint background, selection highlight, labels and border. pub fn paint(&mut self) { - let (scale, sel) = (self.scale, self.sel); - let elem = self.layout.elem(sel as i32).scaled(scale); + let (scale, sel, scroll) = (self.scale, self.sel, self.scroll); // Gather geometry before borrowing the chrome and the labels together. + let elem = self + .layout + .elem(sel as i32, scroll) + .map(|r| r.scaled(scale)); let label_boxes: Vec<(usize, Rect)> = (0..self.tiles.len()) - .filter_map(|i| self.layout.label(i as i32).map(|r| (i, r.scaled(scale)))) + .filter_map(|i| { + self.layout + .label(i as i32, scroll) + .map(|r| (i, r.scaled(scale))) + }) .collect(); + let bar = self + .layout + .scrollbar(scroll, (self.theme.margin / 3).max(2)) + .map(|(track, thumb)| (track.scaled(scale), thumb.scaled(scale))); let t = &self.theme; let (bg, sel_bg, fg, sel_fg, border, border_px) = ( t.bg, @@ -175,13 +213,21 @@ impl App { let mut p = chrome.painter(); p.fill(bg); // The selection fills the whole element box, padding included — the same - // thing rofi's element background does. - p.rect(elem, sel_bg); + // thing rofi's element background does. It can be scrolled out of sight. + if let Some(elem) = elem { + p.rect(elem, sel_bg); + } if let Some(labels) = labels { for (i, at) in label_boxes { labels.draw(&mut p, i, at, if i == sel { sel_fg } else { fg }); } } + // A scrollbar only appears when there is something to scroll, which + // makes it a hint rather than furniture. + if let Some((track, thumb)) = bar { + p.rect(track, bg); + p.rect(thumb, border); + } p.frame(border_px, border); let surface = self.surface.clone().expect("show() runs first"); @@ -195,19 +241,29 @@ impl App { if n == 0 { return; } - self.sel = (self.sel as i32 + delta).rem_euclid(n) as usize; - self.paint(); + self.select((self.sel as i32 + delta).rem_euclid(n) as usize); } fn move_row(&mut self, rows: i32) { let n = self.tiles.len() as i32; let target = self.sel as i32 + rows * self.layout.cols; if target >= 0 && target < n { - self.sel = target as usize; - self.paint(); + self.select(target as usize); } } + /// Move the selection, scrolling the least that keeps it on screen. Every + /// keyboard move goes through here, so the selection is never off-view. + fn select(&mut self, i: usize) { + self.sel = i; + let scroll = self.layout.reveal(i, self.scroll); + if scroll != self.scroll { + self.scroll = scroll; + self.needs_tiles = true; + } + self.paint(); + } + /// The tile under the pointer, if it is over one. A tile's own subsurface /// answers directly; over the parent surface — padding, labels, gaps — the /// layout is asked instead. @@ -219,7 +275,7 @@ impl App { .position(|t| t.surface.as_ref() == Some(&hover.surface)); on_tile.or_else(|| { (Some(&hover.surface) == self.surface.as_ref()) - .then(|| self.layout.hit(hover.x as i32, hover.y as i32)) + .then(|| self.layout.hit(hover.x as i32, hover.y as i32, self.scroll)) .flatten() }) } @@ -251,14 +307,10 @@ impl App { KEY_LEFT | KEY_H => self.move_sel(-1), KEY_DOWN | KEY_J => self.move_row(1), KEY_UP | KEY_K => self.move_row(-1), - KEY_HOME => { - self.sel = 0; - self.paint(); - } - KEY_END => { - self.sel = self.tiles.len().saturating_sub(1); - self.paint(); - } + KEY_HOME => self.select(0), + KEY_END => self.select(self.tiles.len().saturating_sub(1)), + KEY_PGUP => self.move_row(-self.layout.visible_rows), + KEY_PGDN => self.move_row(self.layout.visible_rows), _ => {} } } diff --git a/src/theme.rs b/src/theme.rs index c80b369..b2f31ea 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -66,7 +66,9 @@ impl Default for Theme { #[derive(Debug)] pub struct Layout { pub cols: i32, + /// Rows the whole grid needs, and how many of them fit on screen at once. pub rows: i32, + pub visible_rows: i32, /// How many tiles there are, which the last row may not fill. n: i32, pub width: i32, @@ -82,37 +84,42 @@ pub struct Layout { labels: bool, } -/// How much of the display the grid may occupy before tiles start shrinking. +/// How much of the display the grid may occupy. const FILL: i32 = 90; impl Layout { /// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't /// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses. /// - /// `display` is the logical size of the screen this will appear on. Tiles are - /// the size the theme asks for until the grid would outgrow the screen, at - /// which point they shrink together — keeping their aspect — so thirty - /// windows give small tiles rather than a surface larger than the monitor. + /// Tiles are the size the theme asks for — a configured size that quietly + /// shrank would be a setting ignored — so when the grid needs more rows than + /// the display can show, the extra rows scroll. Only a tile too large for + /// even one row or column is shrunk, since then something has to give. pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self { + let (dw, dh) = (display.0.max(1), display.1.max(1)); + let room_w = (dw * FILL / 100 - 2 * t.margin).max(1); + let room_h = (dh * FILL / 100 - 2 * t.margin).max(1); + let label_row = if t.labels { t.spacing + t.line_h } else { 0 }; + let (tile_w, tile_h) = shrink_to_one(t, label_row, room_w, room_h); + let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad); + + // Columns: the balanced rule, capped by the config and by what fits. let mut cols = (n as f64).sqrt() as i32; if cols * cols < n { cols += 1; } - cols = cols.clamp(1, t.max_cols); + let fit_cols = ((room_w + t.gap) / (elem_w + t.gap)).max(1); + cols = cols.clamp(1, t.max_cols.min(fit_cols)).max(1); let rows = (n + cols - 1) / cols; - // An element is the thumbnail, optionally a label under it, and padding. - let label_row = if t.labels { t.spacing + t.line_h } else { 0 }; - let (tile_w, tile_h) = fit_grid(t, n, cols, rows, label_row, display); - let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad); + let visible_rows = ((room_h + t.gap) / (elem_h + t.gap)).clamp(1, rows.max(1)); + Self { cols, rows, + visible_rows, n, - // Padding, gaps and label rows can outgrow a small display all by - // themselves, so the surface is capped: better a clipped last row - // than asking for a window larger than the screen showing it. - width: (cols * elem_w + (cols - 1) * t.gap + 2 * t.margin).min(display.0.max(1)), - height: (rows * elem_h + (rows - 1) * t.gap + 2 * t.margin).min(display.1.max(1)), + width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin, + height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin, elem_w, elem_h, margin: t.margin, @@ -125,43 +132,82 @@ impl Layout { } } - /// The element box for index i — what the selection highlight fills. - pub fn elem(&self, i: i32) -> Rect { - let (col, row) = (i % self.cols, i / self.cols); - Rect { - x: self.margin + col * (self.elem_w + self.gap), - y: self.margin + row * (self.elem_h + self.gap), - w: self.elem_w, - h: self.elem_h, - } + /// The furthest the viewport can scroll, in rows. + pub fn max_scroll(&self) -> i32 { + (self.rows - self.visible_rows).max(0) } - /// The thumbnail box for index i: the top of the element, above the label. - pub fn tile(&self, i: i32) -> Rect { - let e = self.elem(i); - Rect { + pub fn scrollable(&self) -> bool { + self.max_scroll() > 0 + } + + pub fn row_of(&self, i: usize) -> i32 { + i as i32 / self.cols + } + + /// Where the viewport must sit for tile `i` to be on screen, moving as + /// little as possible from `scroll`. + pub fn reveal(&self, i: usize, scroll: i32) -> i32 { + let row = self.row_of(i); + let top = row.min(scroll); + let bottom = (row - self.visible_rows + 1).max(top); + bottom.clamp(0, self.max_scroll()) + } + + /// The element box for tile `i` with the viewport at `scroll`, or None when + /// that tile is scrolled out of sight. This is what the selection fills. + pub fn elem(&self, i: i32, scroll: i32) -> Option { + let (col, row) = (i % self.cols, i / self.cols); + let visible = row - scroll; + if i < 0 || i >= self.n || visible < 0 || visible >= self.visible_rows { + return None; + } + Some(Rect { + x: self.margin + col * (self.elem_w + self.gap), + y: self.margin + visible * (self.elem_h + self.gap), + w: self.elem_w, + h: self.elem_h, + }) + } + + /// The thumbnail box for tile `i`: the top of the element, above the label. + pub fn tile(&self, i: i32, scroll: i32) -> Option { + self.elem(i, scroll).map(|e| Rect { x: e.x + self.pad, y: e.y + self.pad, w: e.w - 2 * self.pad, h: self.tile_h, + }) + } + + /// The single line of text under the thumbnail, if labels are drawn. + pub fn label(&self, i: i32, scroll: i32) -> Option { + if !self.labels { + return None; } + self.tile(i, scroll).map(|t| Rect { + x: t.x, + y: t.y + t.h + self.spacing, + w: t.w, + h: self.line_h, + }) } /// The tile at a point in surface-local coordinates, if any. Points in the /// gaps between elements and in the window margin belong to nothing, and so /// do the empty cells of a ragged last row. - pub fn hit(&self, x: i32, y: i32) -> Option { - let col = self.axis(x, self.margin, self.elem_w, self.cols)?; - let row = self.axis(y, self.margin, self.elem_h, self.rows)?; + pub fn hit(&self, x: i32, y: i32, scroll: i32) -> Option { + let col = self.axis(x, self.elem_w, self.cols)?; + let row = self.axis(y, self.elem_h, self.visible_rows)? + scroll; let i = row * self.cols + col; - (i < self.n).then_some(i as usize) + (i >= 0 && i < self.n).then_some(i as usize) } /// Which cell along one axis a coordinate falls in, or None if it landed in /// the margin or a gap. - fn axis(&self, v: i32, margin: i32, elem: i32, count: i32) -> Option { + fn axis(&self, v: i32, elem: i32, count: i32) -> Option { let pitch = elem + self.gap; - let offset = v - margin; + let offset = v - self.margin; if offset < 0 { return None; } @@ -169,43 +215,37 @@ impl Layout { (cell < count && offset % pitch < elem).then_some(cell) } - /// The single line of text under the thumbnail, if labels are drawn. - pub fn label(&self, i: i32) -> Option { - if !self.labels { + /// Track and thumb for a scrollbar down the right margin, or None when + /// everything already fits. + pub fn scrollbar(&self, scroll: i32, width: i32) -> Option<(Rect, Rect)> { + if !self.scrollable() { return None; } - let t = self.tile(i); - Some(Rect { - x: t.x, - y: t.y + t.h + self.spacing, - w: t.w, - h: self.line_h, - }) + let track = Rect { + x: self.width - self.margin + (self.margin - width) / 2, + y: self.margin, + w: width, + h: self.height - 2 * self.margin, + }; + let span = (track.h * self.visible_rows / self.rows).max(width); + let travel = track.h - span; + let thumb = Rect { + y: track.y + travel * scroll / self.max_scroll(), + h: span, + ..track + }; + Some((track, thumb)) } } -/// The largest tile that keeps a `cols`x`rows` grid inside `FILL`% of the -/// display, never larger than the theme asks for, and at least a pixel. Both -/// axes shrink by the same factor, so a tile keeps its shape. -fn fit_grid( - t: &Theme, - n: i32, - cols: i32, - rows: i32, - label_row: i32, - (dw, dh): (i32, i32), -) -> (i32, i32) { +/// A tile larger than one row or column of the display has to give way, since +/// nothing can be shown otherwise. Both axes shrink together, keeping its shape. +fn shrink_to_one(t: &Theme, label_row: i32, room_w: i32, room_h: i32) -> (i32, i32) { let (want_w, want_h) = (t.tile_w.max(1), t.tile_h.max(1)); - if n == 0 || dw <= 0 || dh <= 0 { - return (want_w, want_h); - } - // Everything in the surface that is not thumbnail. - let chrome_w = cols * 2 * t.pad + (cols - 1) * t.gap + 2 * t.margin; - let chrome_h = rows * (2 * t.pad + label_row) + (rows - 1) * t.gap + 2 * t.margin; - let room_w = (dw * FILL / 100 - chrome_w).max(cols); - let room_h = (dh * FILL / 100 - chrome_h).max(rows); - let scale = (room_w as f32 / (cols * want_w) as f32) - .min(room_h as f32 / (rows * want_h) as f32) + let cell_w = want_w + 2 * t.pad; + let cell_h = want_h + label_row + 2 * t.pad; + let scale = (room_w as f32 / cell_w as f32) + .min(room_h as f32 / cell_h as f32) .min(1.0); ( ((want_w as f32 * scale) as i32).max(1), @@ -292,10 +332,10 @@ mod tests { for n in 1..=20 { let l = Layout::new(&t, n, ROOMY); for i in 0..n { - let e = l.elem(i); + let e = l.elem(i, 0).expect("visible"); assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}"); assert!(e.y >= 0 && e.y + e.h <= l.height, "n = {n}, i = {i}"); - let tile = l.tile(i); + let tile = l.tile(i, 0).expect("visible"); assert!(tile.w == t.tile_w && tile.h == t.tile_h); } } @@ -309,12 +349,16 @@ mod tests { let without = Layout::new(&t, 4, ROOMY); let rows = 2; assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h)); - assert!(without.label(0).is_none()); + assert!(without.label(0, 0).is_none()); let t = Theme::default(); let l = Layout::new(&t, 4, ROOMY); for i in 0..4 { - let (tile, label, elem) = (l.tile(i), l.label(i).unwrap(), l.elem(i)); + let (tile, label, elem) = ( + l.tile(i, 0).expect("visible"), + l.label(i, 0).unwrap(), + l.elem(i, 0).expect("visible"), + ); assert_eq!(tile.h, t.tile_h); assert_eq!(label.y, tile.y + tile.h + t.spacing); assert_eq!(label.w, tile.w); @@ -324,21 +368,29 @@ mod tests { } #[test] - fn the_grid_shrinks_to_fit_a_small_display() { - let t = Theme::default(); - let big = Layout::new(&t, 12, ROOMY); - assert_eq!(big.tile(0).w, t.tile_w, "no clamping when there is room"); - - // Twenty tiles at full size cannot fit a 1024x768 screen. - let small = Layout::new(&t, 20, (1024, 768)); - assert!( - small.width <= 1024 && small.height <= 768, - "{small:?} overflows" + fn a_tile_too_big_for_one_cell_is_the_only_thing_that_shrinks() { + let mut t = Theme::default(); + let roomy = Layout::new(&t, 12, ROOMY); + assert_eq!( + roomy.tile(0, 0).expect("visible").w, + t.tile_w, + "left alone when there is room" ); - assert!(small.tile(0).w < t.tile_w, "tiles should have shrunk"); + + // A tile wider and taller than the whole screen has to give way, since + // otherwise there is nothing to show. + t.tile_w = 2000; + t.tile_h = 1500; + let l = Layout::new(&t, 4, (800, 600)); + let tile = l.tile(0, 0).expect("visible"); + assert!(tile.w < t.tile_w && tile.h < t.tile_h, "should have shrunk"); + assert!(l.width <= 800 && l.height <= 600, "{l:?}"); + assert_eq!(l.cols, 1, "only one column can fit"); // Shrinking keeps the tile's shape. - let want = t.tile_w as f32 / t.tile_h as f32; - let got = small.tile(0).w as f32 / small.tile(0).h as f32; + let (want, got) = ( + t.tile_w as f32 / t.tile_h as f32, + tile.w as f32 / tile.h as f32, + ); assert!( (want - got).abs() < 0.05, "aspect {got} drifted from {want}" @@ -348,10 +400,11 @@ mod tests { #[test] fn a_tiny_display_never_gets_an_oversized_surface() { let t = Theme::default(); - // Thirty windows on a 640x480 screen: the padding and label rows alone - // do not fit, so tiles bottom out and the surface is capped instead. + // Thirty windows on a 640x480 screen: only a row or two can be shown, + // and the rest scroll. let l = Layout::new(&t, 30, (640, 480)); - assert!(l.tile(0).w >= 1 && l.tile(0).h >= 1, "{l:?}"); + let tile = l.tile(0, 0).expect("visible"); + assert!(tile.w >= 1 && tile.h >= 1, "{l:?}"); assert!(l.width <= 640 && l.height <= 480, "{l:?}"); } @@ -361,32 +414,104 @@ mod tests { // 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 e = l.elem(i); + let e = l.elem(i, 0).expect("visible"); for (x, y, what) in [ (e.x, e.y, "top left"), (e.x + e.w / 2, e.y + e.h / 2, "centre"), (e.x + e.w - 1, e.y + e.h - 1, "bottom right"), ] { - assert_eq!(l.hit(x, y), 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), None, "margin"); - let first = l.elem(0); + assert_eq!(l.hit(0, 0, 0), None, "margin"); + let first = l.elem(0, 0).expect("visible"); assert_eq!( - l.hit(first.x + first.w + 1, first.y), + l.hit(first.x + first.w + 1, first.y, 0), None, "gap between columns" ); assert_eq!( - l.hit(first.x, first.y + first.h + 1), + l.hit(first.x, first.y + first.h + 1, 0), None, "gap between rows" ); - let empty = l.elem(8); // row 2, column 2: past the seventh tile - assert_eq!(l.hit(empty.x + 4, empty.y + 4), None, "empty cell"); - assert_eq!(l.hit(-5, -5), None, "outside"); + // 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::default(); + // Thirty tiles cannot fit; the configured tile size must survive anyway. + let l = Layout::new(&t, 30, (1280, 1440)); + assert_eq!( + l.tile(0, 0).expect("visible").w, + t.tile_w, + "tiles kept their size" + ); + assert!(l.scrollable(), "{l:?} should scroll"); + assert!(l.visible_rows < l.rows); + assert!(l.height <= 1440 && l.width <= 1280, "{l:?}"); + + // 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 revealing_moves_the_viewport_as_little_as_possible() { + let t = Theme::default(); + let l = Layout::new(&t, 30, (1280, 1440)); + 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::default(); + let l = Layout::new(&t, 30, (1280, 1440)); + 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::default(); + assert!(Layout::new(&t, 4, ROOMY).scrollbar(0, 4).is_none()); + let l = Layout::new(&t, 30, (1280, 1440)); + 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]