Skip to content

gpui: hold hover still while the wheel scrolls under a still pointer - #57

Merged
sunli829 merged 1 commit into
mainfrom
fast-wheel-hover-freeze
Oct 12, 2026
Merged

sunli829 merged 1 commit into
mainfrom
fast-wheel-hover-freeze

Conversation

@sunli829

Copy link
Copy Markdown
Collaborator

Experimental, stacked on #56. Changes hover semantics for every app, so it is a separate PR for discussion.

A wheel moves what is under the pointer without the pointer moving: every frame of a wheel scroll hovered another row, rendering the row it left and the row it entered again. A hover could also start an animation (a hover card fading in, a toolbar showing) that kept the list off its layer. In Allsum's long transcript, wheel scrolling cost about what it costs without layers.

For 100 ms after a wheel event, while the pointer stays where the event was, nothing is hovered: the frame's hit test, and the one a list foretells for its rows, are empty. When the wheel stops the window is drawn again (a timer refreshes it) and hover follows the pointer; a pointer that moves ends it at once.

  • fast/layers/hover_freeze.rs: the state (per window, in WindowLayers), note_wheel (from input::before_dispatch), begin_draw (from retained::begin_frame), hit_test.
  • window.rs: mouse_hit_test made pub(crate); the post-prepaint hit test goes through hover_freeze::hit_test. script/upstream-allowlist raises window.rs's added budget from 80 to 82 for these two lines.
  • gpui's own unit tests scroll under a still pointer to test hover as rows move, so the freeze is off under cfg(test); hover_held_still_while_the_wheel_scrolls_renders_no_row_for_it turns it on (and fails with it off).

Measured in Allsum (experimental build, same binary, toggled at runtime): wheel CPU 37 % without, 21 % with; drag unchanged (~20 %).

Open questions:

  • Clearing hover (rather than keeping the last hovered element) makes a hovered row lose its hover style for the duration of the scroll. Hitbox ids are per frame, so keeping the old hit test is not safe as is.
  • Part of the gain may come from hover-started animations rather than hover itself; an investigation of which requests drop the layer during wheel scrolling (GPUI Kit's scrollbar fade, ai-chat's outline, a chart's presence animation) is ongoing.

script/check-upstream: 0 violations. cargo test -p gpui --features test-support --lib (681 passed), clippy pass.

🤖 Generated with Claude Code

@sunli829
sunli829 marked this pull request as ready for review October 12, 2026 02:36
@sunli829
sunli829 force-pushed the fast-chat-scroll-cpu branch from 35cd970 to 7c61fc5 Compare October 12, 2026 03:47
@sunli829
sunli829 force-pushed the fast-wheel-hover-freeze branch from 8d081df to 3f0b375 Compare October 12, 2026 03:50
sunli829 added a commit that referenced this pull request Oct 12, 2026
#56)

Users of Allsum reported high CPU when dragging the scrollbar of a long
conversation. Profiling Allsum (release build, a long "Greeting" session
with charts, tables and markdown) showed the transcript list's scroll
layer bought almost nothing: rows were rendered again on every frame,
and the layer was demoted or kept off on most frames. Three fixes here,
one commit each.

**Stacked on #48.** The base branch `fast-robust-retention-on-main` is
#48 merged into current `main` (conflict in `fast/tests/mod.rs`
resolved), so this diff shows only these changes.

### 1. A list row's writes while it is drawn are its own
Writes made while a row was laid out, prepainted or painted counted as
someone else's. A chart filling its own path cache as it paints made its
row (and every row like it) render again on every scrolled frame. Those
writes are now the row's own, as a view's writes while it renders
already are. The write generation is shared with the thread like the
read logs, for the list to read without an app at hand.

Test: `rows_writing_what_they_read_as_they_are_drawn_keep_the_layer`
(fails without the fix: every shown row re-renders per scroll).

### 2. Replay a list's layer instead of compositing tiles
A list's layer keeps its rows' paint operations and replays those
reaching the viewport into the frame, moved by the scroll. No tiles are
rasterized for lists, so:
- rows painting paths, or drawing over earlier rows' paths, are replayed
in order (no overlay, no keep-off);
- a background that cannot be baked (translucent window) no longer
matters;
- a list's layer is not demoted: repainting costs what drawing without
the layer costs.

Overscan and the per-frame render budget are unchanged. `OverlayReach`
(lists only) is removed.

Tests that encoded tile, overlay or demotion behaviour for lists were
rewritten to assert the new behaviour; every `matches_layers_off`
comparison passes unchanged. `composites()` in the test helpers now also
counts replayed frames.

This is the change most worth discussing: it trades GPU-side tile
compositing for CPU-side replay of display lists for lists. In Allsum it
was the largest single win for scrollbar drag.

### 3. A list's scrollbar drag is a scroll
`ListState::set_offset_from_scrollbar` bumped the list's state version,
which counts content changes too, so every drag frame repainted the
layer. It now notes the offset like other code-set offsets
(`ScrollLog::take_offsets_set`, matched to the painted list by its
state), which counts as a scroll. Without layers the version still
moves.

Test: `a_list_dragged_by_its_scrollbar_is_composited` (fails without the
fix).

### Measured in Allsum (experimental build: these fixes, the hover
freeze of the stacked PR and the app-side fixes; the zero-overscan
variant of replay. Without hover freeze, wheel CPU was ~37 %; drag was
unchanged)
| | wheel CPU | drag CPU |
|---|---|---|
| main | 54 % | 36 % |
| with fixes | ~22 % | ~19 % |


### Measured again with the code of these PRs
Allsum release build against this branch stack (#56, #57 and the
lingering-nodes fix of #55), GPUI Kit's draft fix and
longbridge/ai-chat#438, same session, quiet machine:

| | wheel CPU | drag CPU |
|---|---|---|
| main | 47.2 % | 32.4 % |
| these PRs | 21.9 % | 19.7 % (~2.2 ms/frame) |

Keeping the overscan unchanged (unlike the experimental build) lost
nothing.

The app-side fixes are in separate PRs (GPUI Kit, ai-chat). Hover
freezing while the wheel scrolls is a separate draft stacked on this
one.

`script/check-upstream`: 0 violations. `cargo test -p gpui --features
test-support --lib` (681 passed), `cargo test -p gpui_perf`, `cargo
clippy -p gpui --features test-support --lib --tests` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Base automatically changed from fast-chat-scroll-cpu to main October 12, 2026 04:37
A wheel moves what is under the pointer without the pointer moving: every
frame of the scroll hovered another row, rendering the row it left and the
row it entered again, and a hover could start an animation (a card fading
in, a toolbar showing) that kept the list off its layer. In a long chat
transcript a frame scrolled by the wheel cost about what it costs without
layers.

For 100 ms after a wheel event, while the pointer stays where the event
was, nothing is hovered now: the frame's hit test, and the one a list
foretells for its rows, are empty. When the wheel stops the window is drawn
again and hover follows the pointer; a pointer that moves ends it at once.

The crate's own tests, which scroll under a still pointer to test hover as
rows move, turn it off; one test turns it on.
@sunli829
sunli829 force-pushed the fast-wheel-hover-freeze branch from 3f0b375 to d14b0a1 Compare October 12, 2026 04:37
@sunli829
sunli829 merged commit 028f0f1 into main Oct 12, 2026
4 checks passed
@sunli829
sunli829 deleted the fast-wheel-hover-freeze branch October 12, 2026 04:52
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
Hover held still while a wheel scrolls under a still pointer. Begins
beside our fast::focus::begin_frame in retained::begin_frame; window.rs's
allowlist removed budget rises by the two lines longbridge#57 replaces. Our
scrollbar-drag and wheel rules (bb41023, e1a4746) are untouched: a drag
moves the pointer, which ends the freeze, and sends no wheel event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
Hover held still while the wheel scrolls (longbridge#57) cleared every hover, so
the first frame of a scroll built again every view hovered around the
list, a sidebar's panel among them, for a hover that had not changed.
Its end refreshed the window, building every view again.

Of what was hovered when the wheel began, what has not moved now stays
hovered and nothing else is; the list's foretold hit test holds the
same. The end of the freeze hit tests the pointer again and marks the
window dirty, so only what hovers differently is built again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee pushed a commit that referenced this pull request Oct 16, 2026
#57 holds hover styling still for 100 ms after a wheel event while the
pointer stays put. It did this by emptying the window's hit test
(`mouse_hit_test`). The cursor is resolved from that same hit test, so
every frozen frame set the arrow over text, and the next wheel event
restored the text cursor: the cursor flickered while scrolling.

## Change

- The hit test is no longer emptied during the freeze; it stays the
geometry under the pointer. `hover_freeze::begin_draw` is removed and
`hover_freeze::hit_test` always returns the real hit test.
- The freeze moves into hover itself: `HitboxId::is_hovered` (and
`hovered_now`, its retained-view twin) returns `false` while
`hover_freeze::frozen` is true.
- Cursor resolution uses `is_hovered_ignoring_last_input`, which doesn't
check the freeze, so the cursor follows the geometry under the pointer.
`reset_cursor_style` is unchanged.
- A list's foretold hit test still stays empty while frozen
(`fast/layers/lists.rs`), so rows aren't rendered again for hover.

## Tests

- New:
`the_cursor_follows_the_text_under_a_still_pointer_while_hover_is_frozen`.
The text cursor stays over text throughout the freeze, and the arrow
comes back once the pointer leaves the text.
- Updated:
`hover_held_still_while_the_wheel_scrolls_renders_no_row_for_it` now
asserts that the hit test is non-empty while no hitbox reports hovered.
- Test helpers: `TestPlatform::active_cursor_style` and
`Window::set_owns_pointer` (`#[cfg(test)]`).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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