Scroll when there are more tiles than fit

Shrinking the grid to fit meant a configured tile-width was quietly
ignored the moment enough windows were open — a setting that silently
does not apply is a bug, not a policy. Tiles are now the size the config
asks for and the extra rows scroll.

The layout became a viewport: it reports how many rows fit, elem/tile/
label take a scroll offset and return nothing for tiles above or below
the fold, and hit-testing follows the offset so a click lands on what is
under the cursor rather than what used to be there. Any keyboard move
goes through select(), which scrolls the least that keeps the selection
visible; PgUp/PgDn jump a screen. A scrollbar appears in the right margin
only when there is something to scroll, so it reads as a hint rather than
furniture.

Tiles scrolled out of sight get a null buffer, which unmaps their
subsurface, and the live clock skips them — a long list no longer spends
readback bandwidth on pixels nobody can see. Re-placing the subsurfaces
happens once per dispatch in the event loop rather than inside the key
handler, so holding an arrow key coalesces.

Shrinking survives for one case only: a tile too large for even a single
row or column, where otherwise nothing could be drawn. The old
shrink-to-fit test was rewritten around that, since its premise (twenty
tiles at full size cannot fit) is now answered by scrolling instead.

Verified against the session: 16 tiles at tile-width = 40ppt gives a 2x8
grid with 2 rows visible; three Downs scroll to row 2 and Enter returns
index 6, matching its own list; 82% of the viewport changes across the
scroll; and the scrollbar thumb measures 634px tall at y=24 unscrolled
and y=658 at scroll 2, both what the geometry predicts.
This commit is contained in:
Milad Alizadeh
2026-08-31 18:20:03 +01:00
parent 487acb8b6a
commit 30c478872a
7 changed files with 355 additions and 147 deletions
+13 -7
View File
@@ -95,7 +95,7 @@ chooser_cmd=wl-pick --format portal
|---|---| |---|---|
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile | | `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
| `↓` `↑` / `j` `k` | move a row | | `↓` `↑` / `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 | | `Enter` | pick the selection |
| `Escape` / `q` | cancel | | `Escape` / `q` | cancel |
| click | pick that tile | | 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 wrong on the other. The overlay is mapped explicitly on that display, at that
display's scale, so mixed-DPI renders crisply either way. display's scale, so mixed-DPI renders crisply either way.
`tile-width` and `tile-height` are maxima for the thumbnail cell. Give only the `tile-width` and `tile-height` set how big a thumbnail actually is. Give only
width and the height follows the display's aspect, which is roughly the shape of the width and the height follows the display's aspect, which is roughly the shape
the windows on it — a 16:9 cell wastes about half its area on a portrait monitor. of the windows on it — a 16:9 cell wastes about half its area on a portrait
If the grid would outgrow the display, tiles shrink together and keep their monitor.
shape, so thirty windows give small tiles rather than a surface larger than the
screen. 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 ## Look
+12
View File
@@ -88,6 +88,10 @@ pub struct App {
pub(crate) fps: u32, pub(crate) fps: u32,
pub(crate) scale: i32, pub(crate) scale: i32,
pub(crate) sel: usize, 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, pub(crate) shift: bool,
/// Where the pointer is, and which tile it pressed. Hovering deliberately /// Where the pointer is, and which tile it pressed. Hovering deliberately
@@ -185,6 +189,8 @@ impl App {
fps, fps,
scale, scale,
sel: 0, sel: 0,
scroll: 0,
needs_tiles: false,
shift: false, shift: false,
hover: None, hover: None,
pressed: None, pressed: None,
@@ -243,6 +249,12 @@ impl App {
self.stats.pool_bytes >> 20, self.stats.pool_bytes >> 20,
self.labels.as_ref().map(|l| l.family()).unwrap_or("none"), 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. /// What live capture actually did, once the overlay is closing.
+5
View File
@@ -328,6 +328,11 @@ impl App {
if self.tiles[i].target.kind == Kind::Output { if self.tiles[i].target.kind == Kind::Output {
continue; 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]; let t = &self.tiles[i];
if t.slots.is_empty() || t.frame.is_some() { if t.slots.is_empty() || t.frame.is_some() {
continue; continue;
+4 -4
View File
@@ -29,8 +29,8 @@ usage: wl-pick [options]
-v, --verbose phase timings, tile list and capture stats -v, --verbose phase timings, tile list and capture stats
-h, --help this -h, --help this
keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks; keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
Escape or q cancels Enter picks; Escape or q cancels
mouse: click a tile to pick it, scroll to move. Hovering does not move the mouse: click a tile to pick it, scroll to move. Hovering does not move the
selection, and a click outside a tile does nothing. selection, and a click outside a tile does nothing.
@@ -51,9 +51,9 @@ config:
border = #d79921 border = #d79921
border-width = 2px 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 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 = monospace # also --font
font-size = 13.3 font-size = 13.3
+11 -3
View File
@@ -101,7 +101,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
theme.font.clone(), theme.font.clone(),
theme.font_px * scale as f32, theme.font_px * scale as f32,
(theme.line_h * 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<ExitCode, Box<dyn Error>> {
app.show(&qh)?; app.show(&qh)?;
pump(&mut queue, &mut app, |a| a.configured)?; pump(&mut queue, &mut app, |a| a.configured)?;
app.paint(); app.paint();
app.place_tiles(&qh); app.sync_tiles(&qh);
app.arm_frame_callback(&qh); app.arm_frame_callback(&qh);
conn.flush()?; conn.flush()?;
phases.mark("mapped"); 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 { if opts.verbose {
app.report(start.elapsed()); app.report(start.elapsed());
} }
+91 -39
View File
@@ -47,6 +47,8 @@ const KEY_LEFT: u32 = 105;
const KEY_RIGHT: u32 = 106; const KEY_RIGHT: u32 = 106;
const KEY_END: u32 = 107; const KEY_END: u32 = 107;
const KEY_DOWN: u32 = 108; const KEY_DOWN: u32 = 108;
const KEY_PGUP: u32 = 104;
const KEY_PGDN: u32 = 109;
/// evdev button code, as wl_pointer reports it. /// evdev button code, as wl_pointer reports it.
const BTN_LEFT: u32 = 0x110; const BTN_LEFT: u32 = 0x110;
@@ -107,11 +109,20 @@ impl App {
Ok(()) Ok(())
} }
/// Attach each captured buffer to its own subsurface and let the compositor /// Put every visible tile where the viewport says, and unmap the rest.
/// scale it into the tile rectangle. ///
pub fn place_tiles(&mut self, qh: &QueueHandle<Self>) { /// 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<Self>) {
let parent = self.surface.clone().expect("show() runs first"); let parent = self.surface.clone().expect("show() runs first");
let scroll = self.scroll;
for i in 0..self.tiles.len() { 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 { if !self.tiles[i].ready {
continue; continue;
} }
@@ -121,42 +132,69 @@ impl App {
} else { } else {
(bw as i32, bh as i32) (bw as i32, bh as i32)
}; };
let dst = fit_centred(fit_w, fit_h, self.layout.tile(i as i32)); let dst = fit_centred(fit_w, fit_h, box_);
let surface = self.compositor.create_surface(qh, ()); if self.tiles[i].surface.is_none() {
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ()); let surface = self.compositor.create_surface(qh, ());
let viewport = self.viewporter.get_viewport(&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); 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); viewport.set_destination(dst.w, dst.h);
let slot = self.tiles[i].showing.expect("a ready tile has a slot"); surface.attach(Some(&t.slots[slot].buffer), 0, 0);
surface.attach(Some(&self.tiles[i].slots[slot].buffer), 0, 0);
surface.damage_buffer(0, 0, bw as i32, bh as i32); surface.damage_buffer(0, 0, bw as i32, bh as i32);
surface.commit(); 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 // Subsurface placement is *parent* state: it only takes effect when the
// parent commits, desynced children included. // parent commits, desynced children included.
parent.commit(); 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. /// Repaint background, selection highlight, labels and border.
pub fn paint(&mut self) { pub fn paint(&mut self) {
let (scale, sel) = (self.scale, self.sel); let (scale, sel, scroll) = (self.scale, self.sel, self.scroll);
let elem = self.layout.elem(sel as i32).scaled(scale);
// Gather geometry before borrowing the chrome and the labels together. // 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()) 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(); .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 t = &self.theme;
let (bg, sel_bg, fg, sel_fg, border, border_px) = ( let (bg, sel_bg, fg, sel_fg, border, border_px) = (
t.bg, t.bg,
@@ -175,13 +213,21 @@ impl App {
let mut p = chrome.painter(); let mut p = chrome.painter();
p.fill(bg); p.fill(bg);
// The selection fills the whole element box, padding included — the same // The selection fills the whole element box, padding included — the same
// thing rofi's element background does. // thing rofi's element background does. It can be scrolled out of sight.
p.rect(elem, sel_bg); if let Some(elem) = elem {
p.rect(elem, sel_bg);
}
if let Some(labels) = labels { if let Some(labels) = labels {
for (i, at) in label_boxes { for (i, at) in label_boxes {
labels.draw(&mut p, i, at, if i == sel { sel_fg } else { fg }); 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); p.frame(border_px, border);
let surface = self.surface.clone().expect("show() runs first"); let surface = self.surface.clone().expect("show() runs first");
@@ -195,19 +241,29 @@ impl App {
if n == 0 { if n == 0 {
return; return;
} }
self.sel = (self.sel as i32 + delta).rem_euclid(n) as usize; self.select((self.sel as i32 + delta).rem_euclid(n) as usize);
self.paint();
} }
fn move_row(&mut self, rows: i32) { fn move_row(&mut self, rows: i32) {
let n = self.tiles.len() as i32; let n = self.tiles.len() as i32;
let target = self.sel as i32 + rows * self.layout.cols; let target = self.sel as i32 + rows * self.layout.cols;
if target >= 0 && target < n { if target >= 0 && target < n {
self.sel = target as usize; self.select(target as usize);
self.paint();
} }
} }
/// 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 /// 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 /// answers directly; over the parent surface — padding, labels, gaps — the
/// layout is asked instead. /// layout is asked instead.
@@ -219,7 +275,7 @@ impl App {
.position(|t| t.surface.as_ref() == Some(&hover.surface)); .position(|t| t.surface.as_ref() == Some(&hover.surface));
on_tile.or_else(|| { on_tile.or_else(|| {
(Some(&hover.surface) == self.surface.as_ref()) (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() .flatten()
}) })
} }
@@ -251,14 +307,10 @@ impl App {
KEY_LEFT | KEY_H => self.move_sel(-1), KEY_LEFT | KEY_H => self.move_sel(-1),
KEY_DOWN | KEY_J => self.move_row(1), KEY_DOWN | KEY_J => self.move_row(1),
KEY_UP | KEY_K => self.move_row(-1), KEY_UP | KEY_K => self.move_row(-1),
KEY_HOME => { KEY_HOME => self.select(0),
self.sel = 0; KEY_END => self.select(self.tiles.len().saturating_sub(1)),
self.paint(); KEY_PGUP => self.move_row(-self.layout.visible_rows),
} KEY_PGDN => self.move_row(self.layout.visible_rows),
KEY_END => {
self.sel = self.tiles.len().saturating_sub(1);
self.paint();
}
_ => {} _ => {}
} }
} }
+219 -94
View File
@@ -66,7 +66,9 @@ impl Default for Theme {
#[derive(Debug)] #[derive(Debug)]
pub struct Layout { pub struct Layout {
pub cols: i32, pub cols: i32,
/// Rows the whole grid needs, and how many of them fit on screen at once.
pub rows: i32, pub rows: i32,
pub visible_rows: i32,
/// How many tiles there are, which the last row may not fill. /// How many tiles there are, which the last row may not fill.
n: i32, n: i32,
pub width: i32, pub width: i32,
@@ -82,37 +84,42 @@ pub struct Layout {
labels: bool, 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; const FILL: i32 = 90;
impl Layout { impl Layout {
/// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't /// 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. /// 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 /// Tiles are the size the theme asks for — a configured size that quietly
/// the size the theme asks for until the grid would outgrow the screen, at /// shrank would be a setting ignored — so when the grid needs more rows than
/// which point they shrink together — keeping their aspect — so thirty /// the display can show, the extra rows scroll. Only a tile too large for
/// windows give small tiles rather than a surface larger than the monitor. /// even one row or column is shrunk, since then something has to give.
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self { 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; let mut cols = (n as f64).sqrt() as i32;
if cols * cols < n { if cols * cols < n {
cols += 1; 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; let rows = (n + cols - 1) / cols;
// An element is the thumbnail, optionally a label under it, and padding. let visible_rows = ((room_h + t.gap) / (elem_h + t.gap)).clamp(1, rows.max(1));
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);
Self { Self {
cols, cols,
rows, rows,
visible_rows,
n, n,
// Padding, gaps and label rows can outgrow a small display all by width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
// themselves, so the surface is capped: better a clipped last row height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin,
// 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)),
elem_w, elem_w,
elem_h, elem_h,
margin: t.margin, margin: t.margin,
@@ -125,43 +132,82 @@ impl Layout {
} }
} }
/// The element box for index i — what the selection highlight fills. /// The furthest the viewport can scroll, in rows.
pub fn elem(&self, i: i32) -> Rect { pub fn max_scroll(&self) -> i32 {
let (col, row) = (i % self.cols, i / self.cols); (self.rows - self.visible_rows).max(0)
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 thumbnail box for index i: the top of the element, above the label. pub fn scrollable(&self) -> bool {
pub fn tile(&self, i: i32) -> Rect { self.max_scroll() > 0
let e = self.elem(i); }
Rect {
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<Rect> {
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<Rect> {
self.elem(i, scroll).map(|e| Rect {
x: e.x + self.pad, x: e.x + self.pad,
y: e.y + self.pad, y: e.y + self.pad,
w: e.w - 2 * self.pad, w: e.w - 2 * self.pad,
h: self.tile_h, 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<Rect> {
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 /// 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 /// gaps between elements and in the window margin belong to nothing, and so
/// do the empty cells of a ragged last row. /// do the empty cells of a ragged last row.
pub fn hit(&self, x: i32, y: i32) -> Option<usize> { pub fn hit(&self, x: i32, y: i32, scroll: i32) -> Option<usize> {
let col = self.axis(x, self.margin, self.elem_w, self.cols)?; let col = self.axis(x, self.elem_w, self.cols)?;
let row = self.axis(y, self.margin, self.elem_h, self.rows)?; let row = self.axis(y, self.elem_h, self.visible_rows)? + scroll;
let i = row * self.cols + col; 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 /// Which cell along one axis a coordinate falls in, or None if it landed in
/// the margin or a gap. /// the margin or a gap.
fn axis(&self, v: i32, margin: i32, elem: i32, count: i32) -> Option<i32> { fn axis(&self, v: i32, elem: i32, count: i32) -> Option<i32> {
let pitch = elem + self.gap; let pitch = elem + self.gap;
let offset = v - margin; let offset = v - self.margin;
if offset < 0 { if offset < 0 {
return None; return None;
} }
@@ -169,43 +215,37 @@ impl Layout {
(cell < count && offset % pitch < elem).then_some(cell) (cell < count && offset % pitch < elem).then_some(cell)
} }
/// The single line of text under the thumbnail, if labels are drawn. /// Track and thumb for a scrollbar down the right margin, or None when
pub fn label(&self, i: i32) -> Option<Rect> { /// everything already fits.
if !self.labels { pub fn scrollbar(&self, scroll: i32, width: i32) -> Option<(Rect, Rect)> {
if !self.scrollable() {
return None; return None;
} }
let t = self.tile(i); let track = Rect {
Some(Rect { x: self.width - self.margin + (self.margin - width) / 2,
x: t.x, y: self.margin,
y: t.y + t.h + self.spacing, w: width,
w: t.w, h: self.height - 2 * self.margin,
h: self.line_h, };
}) 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 /// A tile larger than one row or column of the display has to give way, since
/// display, never larger than the theme asks for, and at least a pixel. Both /// nothing can be shown otherwise. Both axes shrink together, keeping its shape.
/// axes shrink by the same factor, so a tile keeps its shape. fn shrink_to_one(t: &Theme, label_row: i32, room_w: i32, room_h: i32) -> (i32, i32) {
fn fit_grid(
t: &Theme,
n: i32,
cols: i32,
rows: i32,
label_row: i32,
(dw, dh): (i32, i32),
) -> (i32, i32) {
let (want_w, want_h) = (t.tile_w.max(1), t.tile_h.max(1)); let (want_w, want_h) = (t.tile_w.max(1), t.tile_h.max(1));
if n == 0 || dw <= 0 || dh <= 0 { let cell_w = want_w + 2 * t.pad;
return (want_w, want_h); let cell_h = want_h + label_row + 2 * t.pad;
} let scale = (room_w as f32 / cell_w as f32)
// Everything in the surface that is not thumbnail. .min(room_h as f32 / cell_h as f32)
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)
.min(1.0); .min(1.0);
( (
((want_w as f32 * scale) as i32).max(1), ((want_w as f32 * scale) as i32).max(1),
@@ -292,10 +332,10 @@ mod tests {
for n in 1..=20 { for n in 1..=20 {
let l = Layout::new(&t, n, ROOMY); let l = Layout::new(&t, n, ROOMY);
for i in 0..n { 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.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}"); 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); 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 without = Layout::new(&t, 4, ROOMY);
let rows = 2; let rows = 2;
assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h)); 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 t = Theme::default();
let l = Layout::new(&t, 4, ROOMY); let l = Layout::new(&t, 4, ROOMY);
for i in 0..4 { 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!(tile.h, t.tile_h);
assert_eq!(label.y, tile.y + tile.h + t.spacing); assert_eq!(label.y, tile.y + tile.h + t.spacing);
assert_eq!(label.w, tile.w); assert_eq!(label.w, tile.w);
@@ -324,21 +368,29 @@ mod tests {
} }
#[test] #[test]
fn the_grid_shrinks_to_fit_a_small_display() { fn a_tile_too_big_for_one_cell_is_the_only_thing_that_shrinks() {
let t = Theme::default(); let mut t = Theme::default();
let big = Layout::new(&t, 12, ROOMY); let roomy = Layout::new(&t, 12, ROOMY);
assert_eq!(big.tile(0).w, t.tile_w, "no clamping when there is room"); assert_eq!(
roomy.tile(0, 0).expect("visible").w,
// Twenty tiles at full size cannot fit a 1024x768 screen. t.tile_w,
let small = Layout::new(&t, 20, (1024, 768)); "left alone when there is room"
assert!(
small.width <= 1024 && small.height <= 768,
"{small:?} overflows"
); );
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. // Shrinking keeps the tile's shape.
let want = t.tile_w as f32 / t.tile_h as f32; let (want, got) = (
let got = small.tile(0).w as f32 / small.tile(0).h as f32; t.tile_w as f32 / t.tile_h as f32,
tile.w as f32 / tile.h as f32,
);
assert!( assert!(
(want - got).abs() < 0.05, (want - got).abs() < 0.05,
"aspect {got} drifted from {want}" "aspect {got} drifted from {want}"
@@ -348,10 +400,11 @@ mod tests {
#[test] #[test]
fn a_tiny_display_never_gets_an_oversized_surface() { fn a_tiny_display_never_gets_an_oversized_surface() {
let t = Theme::default(); let t = Theme::default();
// Thirty windows on a 640x480 screen: the padding and label rows alone // Thirty windows on a 640x480 screen: only a row or two can be shown,
// do not fit, so tiles bottom out and the surface is capped instead. // and the rest scroll.
let l = Layout::new(&t, 30, (640, 480)); 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:?}"); 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. // 7 tiles over 3 columns: the last row holds one, so two cells are empty.
let l = Layout::new(&t, 7, ROOMY); let l = Layout::new(&t, 7, ROOMY);
for i in 0..7 { for i in 0..7 {
let e = l.elem(i); 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"),
(e.x + e.w / 2, e.y + e.h / 2, "centre"), (e.x + e.w / 2, e.y + e.h / 2, "centre"),
(e.x + e.w - 1, e.y + e.h - 1, "bottom right"), (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 window margin, the gap between elements, and the empty cells of
// the last row all belong to no tile. // the last row all belong to no tile.
assert_eq!(l.hit(0, 0), None, "margin"); assert_eq!(l.hit(0, 0, 0), None, "margin");
let first = l.elem(0); let first = l.elem(0, 0).expect("visible");
assert_eq!( assert_eq!(
l.hit(first.x + first.w + 1, first.y), l.hit(first.x + first.w + 1, first.y, 0),
None, None,
"gap between columns" "gap between columns"
); );
assert_eq!( assert_eq!(
l.hit(first.x, first.y + first.h + 1), l.hit(first.x, first.y + first.h + 1, 0),
None, None,
"gap between rows" "gap between rows"
); );
let empty = l.elem(8); // row 2, column 2: past the seventh tile // Row 2, column 2 is past the seventh tile: take its column from the top
assert_eq!(l.hit(empty.x + 4, empty.y + 4), None, "empty cell"); // row and its row from the first column.
assert_eq!(l.hit(-5, -5), None, "outside"); 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] #[test]