Skip to content

Scroll panes: move surfaces instead of repainting windows - #192

Merged
nongio merged 28 commits into
mainfrom
perf/scroll-panes
Sep 15, 2026
Merged

nongio merged 28 commits into
mainfrom
perf/scroll-panes

Conversation

@nongio

@nongio nongio commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Scrolling in Otto's own apps now moves surfaces instead of repainting windows. A scroll or pan in Files, Settings, the launcher or the emoji picker is presented by the compositor moving a subsurface. The frosted window behind it is left alone.

This PR has four parts. Each builds on the one before it.

Depends on nongio/layers#28. Cargo.toml points laye-rs at that branch. Re-pin it to the release tag before merging.

1. Compositor: damage only what changed

  • No re-append on every window update (src/state/mod.rs).
    • update_window_view_for_commit used to re-append the root surface layer on every update. Appending detaches and re-attaches the node.
    • A surface layer answers that repaint with the damage of its last buffer. So any style request on a subsurface, such as a band moving, redrew the whole window.
    • It now appends only when the root isn't already under the content layer.
  • Moves aren't repaints (src/surface_config_cache.rs, configure_surface_layer).
    • The config cache keeps two hashes per surface: what it shows (buffer, crop, size) and where it sits.
    • A change in placement only (Reconfigure::Placement) repositions the layer and does not reinstall its draw content.
  • Opaque regions reach the blur (sync_surface_opaque_region).
    • The client's wl_surface.set_opaque_region, as smithay tracks it, is scaled to physical pixels and passed to Layer::set_opaque_region.
    • The frost is then not computed under an opaque listing.
    • It is cached per surface, because setting it repaints the layer.
  • Sampling for client-placed surfaces (surface_filter).
    • A surface whose position the client sets through the style protocol, drawn 1:1, is filtered bilinearly instead of bicubically.
    • A one-pixel buffer (clip, fill) uses nearest.
  • fix(udev): builds with --features metrics again.

2. otto-kit: scroll components

All of this lives in components/otto-kit/src/components/scroll/. The design is in docs/developer/scroll-pane.md (new).

  • Window
    • request_frame_damaged(&[Rect]) sends eglSwapBuffersWithDamage for a partial repaint.
    • set_opaque_region declares opaque pixels.
    • Frame pacing: one outstanding frame request per surface, so windows with a registered frame callback are paced at last.
    • The accessible tree is rebuilt only after a paint.
  • ScrollSurfaces: a clip subsurface plus a band subsurface holding the content around the viewport, plus a thumb.
    • A scroll moves the band. The band is repainted only when the viewport nears its edge.
    • Moves wait for the previous frame to be presented.
    • It works on either axis, and can act as a container for nested panes.
    • Positions snap to whole pixels. The input can pass through to the window.
  • ScrollPane / ScrollContent: physics (ScrollView) plus surfaces plus a content revision.
    • set_highlight is a selection marker that slides between rows on its own surface.
    • hovered() / wheel_at tell you what is under a still pointer while content flings past it.
    • update_container_at moves a container that is driven by physics outside the pane.
  • Fill: a public one-pixel colour surface, stretched and rounded by the compositor. Used for dividers and highlights.
  • RowLayout / GridLayout: row and cell geometry in content coordinates, with hit testing, visible range and marquee.
  • ScrollGroup: gesture axis routing between nested panes.
  • Examples:
    • damage_probe: damage on the wire.
    • virtual_fling: touchpad flings through zwlr_virtual_pointer_v1.
    • scroll_nested_probe: a nested stack.

3. Apps on the components

  • otto-files
    • Columns. The Miller column stack is a horizontal container, clipped to the file area. Each column is a vertical pane placed inside it.
      • A column's tint is its clip colour and its divider is a Fill.
      • Status lines and the docked preview ride in the stack.
      • Scrolling a column or panning the stack doesn't commit the window. The scene loses its per-column layers.
    • List and grid stay window-painted. They report only the file area as damage on a scroll and declare it opaque.
    • Geometry. List and grid geometry and hit testing come from the kit layouts.
    • Palette. The palette result list scrolls on its own pane.
    • Accessibility. Only rows on screen are described, and selection is looked up per drawn row.
  • otto-settings: the pane is a ScrollPane with pass-through input, and its ground is declared opaque.
  • otto-launcher: results scroll on a pane. The highlight follows the row under the pointer during and after a fling.
  • otto-emoji: the category grids are vertical panes in a horizontal container that follows the picker's paging. The selected cell is a pane highlight. Swipes and scrolls move surfaces; the card repaints only its field, tab marker and footer.

Specs updated: file-browser.md, settings-app.md, launcher.md, emoji-picker.md. file-previews.md was touched too.

4. otto-files and kit, restructured

After an architecture review of the result:

  • Kit fixes. Dropping a ScrollSurfaces or ScrollPane destroys its surfaces (EGL first, frame bookkeeping forgotten); a frame the compositor never answers stops holding the next step after 500 ms; a band claims its size after the buffer that fits it; the thumb is re-claimed when the scale lands.
  • PlacedSurface (kit). The painted child surface otto-files kept for itself, now shared: placed at the fractional scale, a size claimed only behind a matching buffer, paints keyed and paced, input withdrawn while hidden. Status lines, the docked preview and its video, the palette card and Quick View use it, which fixes their sizing on fractional scales.
  • One place presents. Key and pointer handlers only change the browser; on_update, which runs after every batch, decides between a window repaint, a file-area repaint, or surfaces only. A column scroll never repaints the window; a thumbnail landing in list or grid repaints the file area.
  • app.rs is app/. One module per concern (navigation, selection, the palette session, file operations, Quick View, keys, lifecycle, presentation), each test module in its own file. The thousand-line pointer closure is Browser::on_pointer: overlays (rename, Quick View, the sheet, the fields, the footer, the path bar) answer first as methods of their own, then the listing by event kind.
  • Removed. The in-window palette fallback, OTTO_FILES_QV_TRACE, OTTO_FILES_PALETTE_TRACE, OTTO_FILES_NO_BLUR, and dead code.

Reading order

The commits are meant to be read one at a time, oldest first: the kit pacing and a11y commits, the compositor commit (perf: damage and blur only what changed), the kit scroll components, the apps moving onto them, and last the restructuring commits (fix(kit): tear down panes… onwards).

Known follow-ups (not in this PR)

  • The palette list and the columns drive ScrollSurfaces::sync_state rather than ScrollPane, because their scroll views live in the browser state its headless tests drive. A pane that borrows its ScrollView would remove that bookkeeping.
  • ScrollGroup has no consumer yet; pinned grid headers as their own surface are not built.
  • A band's subsurface position lands with the clip's next commit (hidden by pass-through input).

Test plan

  • cargo test --release --lib: otto-kit (351), otto-files (384), clippy clean for both, after the restructuring; launcher (24) and emoji (42) before it
  • Takeover runs on hardware:
    • flings in Files (columns, list, grid, palette), Settings, launcher and emoji
    • commit counts per surface from WAYLAND_DEBUG, and screenshots
    • no panics
  • Installed and used on a live session (before the restructuring; the restructured build is being installed now)

https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54

A registered frame callback stopped draw from requesting frames, so a
window with one was never paced. Keep one outstanding request per surface
instead, and rebuild a surface's accessible tree only when something has
painted since the last one.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
A window update no longer re-appends the root surface layer, which repainted the whole window on every subsurface style request. A surface that only moved is repositioned without re-installing its draw content. A client's opaque region is carried onto its layer so the blurred backdrop leaves it out.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
Window::request_frame_damaged reports what a frame changed; Window::set_opaque_region declares opaque pixels. ScrollSurfaces gains input passthrough, hide, a state-driven sync that paces band moves to presented frames, and a thumb stretched rather than repainted while it squashes. Adds the damage_probe and virtual_fling examples used to measure it.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
Each Miller column is a scroll pane of its own, moved by the compositor instead of repainted; the window keeps the column ground, tint and status line. List and grid report only the file area on a scroll, a landed thumbnail repaints only its column, and the file area is declared opaque so the frost skips it. The pane-subsurface flag and the in-window column rows are gone.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
A surface whose position its client sets through the style protocol was never on the pixel grid and so always bicubic, sixteen texels a pixel on every step of a scrolled band. Drawn 1:1 it is now bilinear, and a one-pixel buffer nearest.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
ScrollSurfaces bands along either axis, holds panes as a container, snaps to whole pixels and gives a pass-through clip a one-pixel buffer. Adds RowLayout and GridLayout in content coordinates, ScrollPane and ScrollContent over a view and its surfaces, ScrollGroup for gesture routing, and the scroll_nested_probe example.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The pane's surfaces are transparent over the ground the window paints and pass the pointer through, so every event is hit-tested in window coordinates and the per-surface translation goes. The window marks the pane's ground opaque, so the frost is not computed under it.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The column stack is a horizontal scroll container clipped to the file area, and each column a vertical pane placed once inside it, so a pan moves one surface instead of re-cropping every column and repainting the one under the sidebar. The stack's bar is the container's own, and the preview's player rides in the stack. virtual_fling can drive the horizontal axis.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
Everything that moves with the stack now lives in it: a column's tint is the colour of its clip, the hairline down its edge is a stretched pixel, and its status line and the docked preview are surfaces beside the columns. The window draws nothing that moves with a pan, so a pan no longer repaints it, and the scene loses its per-column layers.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The grid's cells, headings, hit test, visible range and marquee, and the row strips of the list and the columns, are the kit's RowLayout and GridLayout mapped into the file area, in place of their own section walk and closed forms. The kit's grid reserves its padding on both sides and its marquee catches what a flat sweep crosses.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
ScrollSurfaces::set_highlight and ScrollPane::set_highlight mark an item with a rounded wash on a surface of its own between the pane's ground and its band. Moving the selection slides it there and repaints nothing; a scroll moves it with the content.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
On its surface the palette's rows are a scroll pane inside the card: scrolling the list moves the pane's band, and the card is painted again only when its field, prompt, message or size changes.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The result rows are a scroll pane inside the card and the selection is the pane's sliding highlight, so moving the selection or scrolling repaints neither the card nor the rows. A wheel or touchpad over the card now scrolls the list.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The category panes are vertical scroll panes placed side by side in a horizontal container that follows the picker's own paging, with the selected cell marked by a pane highlight. A swipe, a scroll or a new selection moves surfaces; the card is repainted only for its field, tab marker and footer.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
ScrollPane remembers the pointer from its pointer and wheel calls and answers hovered(): the content point under a still pointer through the scroll as it is now, so a selection can follow a fling after the fingers lift. update_container_at moves a container to an offset decided by other physics. Fill is a public one-pixel colour surface, stretched and rounded by the compositor, and the highlight is built on it; the highlight no longer slides while the content scrolls, where it trailed out of the pane.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The launcher and emoji follow the pointer through ScrollPane::hovered instead of tracking it themselves, emoji's stack is a kit container moved with update_container_at, and otto-files' dividers are kit fills. A palette scroll on its own surface no longer repaints the Files window, and the launcher and emoji cards keep painting only while a transition runs rather than after every caret blink.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
ScrollSurfaces destroys its surfaces when dropped, and a destroyed
subsurface releases its EGL surface first and forgets its frame
bookkeeping. A frame unanswered for 500 ms no longer holds the next one
back. A band claims its size after the buffer that fits it, and the
thumb is claimed again when the scale lands.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
PlacedSurface is the painted child surface otto-files kept for itself:
placed and sized through the style protocol at the fractional scale,
claiming a size behind the buffer that fits it, painting only for a new
key and never ahead of the last frame, and withdrawing its input while
hidden. otto-files' status lines, preview, video, palette card, catcher
and Quick View use it, and so follow the output's real scale.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
A column scroll never repaints the window, even on a pass where its band
was held for the last frame: the compositor's answer wakes the loop.
A held surface paint keeps the idle tick running instead of forcing a
window repaint, and a thumbnail landing in the list or grid repaints
the file area alone.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The palette is always on its own surface, so the window no longer paints
a fallback card or gates its pointer, scroll and sync on a setting. The
Quick View and palette trace output and OTTO_FILES_NO_BLUR are gone,
and stale comments follow the code.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
Key and pointer handlers only change the browser; the update loop that
runs after every batch decides whether that repaints the window, the
file area or only surfaces. Changes that repainted only through a
handler's explicit render now mark the browser dirty: an emptied search
field, the rubber band's edge, the pane a context menu moves the
keyboard to, a place picked again, a palette note cleared.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
app.rs becomes app/, one file per concern: the browser's methods by what
they do (navigation, selection, the palette session, file operations,
Quick View and so on), the app's lifecycle, keys, pointer and
presentation, and each test module in a file of its own. The code moves
unchanged; methods are visible across the app module.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
The thousand-line pointer closure becomes Browser::on_pointer: whatever
is up over the listing (a rename, Quick View, the sheet, the fields, the
footer, the path bar) answers first, each a method of its own, then the
listing by kind of event. A drag start and the context menu come back
as work to do once the browser is let go of.

Claude-Session: https://claude.ai/code/session_01NQwfuVAHbrQx7UUyEUgd54
@nongio
nongio merged commit 2920c23 into main Sep 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant