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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-33
@@ -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_);
|
||||||
|
if self.tiles[i].surface.is_none() {
|
||||||
let surface = self.compositor.create_surface(qh, ());
|
let surface = self.compositor.create_surface(qh, ());
|
||||||
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
||||||
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
||||||
subsurface.set_position(dst.x, dst.y);
|
// Tiles change independently of the chrome — a live frame
|
||||||
// Tiles change independently of the chrome — a live frame arrives
|
// arrives whenever its window does — so they must not wait on a
|
||||||
// whenever its window does — so they must not wait on a parent
|
// parent commit.
|
||||||
// commit.
|
|
||||||
subsurface.set_desync();
|
subsurface.set_desync();
|
||||||
// The capture protocol reports the transform the compositor already
|
// The capture protocol reports the transform the compositor
|
||||||
// applied to the buffer, which is exactly what this request means,
|
// already applied to the buffer, which is exactly what this
|
||||||
// so it passes straight through and the compositor un-rotates it.
|
// request means, so it passes straight through.
|
||||||
surface.set_buffer_transform(self.tiles[i].transform);
|
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.damage_buffer(0, 0, bw as i32, bh as i32);
|
|
||||||
surface.commit();
|
|
||||||
let t = &mut self.tiles[i];
|
let t = &mut self.tiles[i];
|
||||||
t.surface = Some(surface);
|
t.surface = Some(surface);
|
||||||
t.subsurface = Some(subsurface);
|
t.subsurface = Some(subsurface);
|
||||||
t.viewport = Some(viewport);
|
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);
|
||||||
|
viewport.set_destination(dst.w, dst.h);
|
||||||
|
surface.attach(Some(&t.slots[slot].buffer), 0, 0);
|
||||||
|
surface.damage_buffer(0, 0, bw as i32, bh as i32);
|
||||||
|
surface.commit();
|
||||||
|
}
|
||||||
// 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.
|
||||||
|
if let Some(elem) = elem {
|
||||||
p.rect(elem, sel_bg);
|
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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user