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
+91 -39
View File
@@ -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<Self>) {
/// 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<Self>) {
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),
_ => {}
}
}