Skip to content

Make a zoom step change only the rows and paint once - #43

Merged
abooodbah merged 6 commits into
mainfrom
perf/zoom-step
Nov 6, 2026
Merged

abooodbah merged 6 commits into
mainfrom
perf/zoom-step

Conversation

@abooodbah

Copy link
Copy Markdown
Owner

What was slow

A zoom step on 0.5.0 took about 265 ms on a six-column file (median of 72 steps, 1400 x 900 window). Most of it was the column widths.

set_grid_zoom (crates/leanrows-win32/src/native.rs:2087 on main) calls set_column_widths (native.rs:5807), which sends LVM_SETCOLUMNWIDTH once per column with redraw on. Each of those messages repaints every visible row before it returns. On top of that the step rebuilt the whole theme, re-applied it to every control, refitted the header and repainted the whole window with RDW_UPDATENOW (native.rs:2096, redraw_shell_children at native.rs:2182).

A traced build (six-column 2 MB file, 72 steps, medians) split the 241 ms step like this:

Part ms
set_column_widths: 7 x LVM_SETCOLUMNWIDTH, 28.6 ms each, 4,095 cell requests per step 176
redraw_shell_children with RDW_UPDATENOW 31
apply_native_presentation 15.7
refit_list_header 8
layout_children 4.2
ThemeResources::new (all fonts, brushes) 0.15
save_zoom_percent 0.17

Turning redraw off around the widths alone took the step from 241 ms to 94 ms. Creating fonts is not the cost (0.15 ms). The column widths also drifted: each step rescaled the width it found, so 100 -> 300 -> 50 -> 100 % turned 112/720 px into 102/715 px, and stepping to 125 % gave 139 px where opening at 125 % gives 140 px.

What changes

  • A step makes the two grid fonts only (GridFonts), gives them to the list and its header and sets the widths with the list's redraw off, then turns redraw back on. That marks the rows and titles for one paint and paints nothing before the step returns. The rest of the window is left alone.
  • Each column keeps the width it was designed at and the width it was last given (ColumnZoom in zoom.rs). A step scales the design width, so widths do not drift. A width the user dragged becomes the new basis.
  • Columns of a newly opened file are inserted at the current zoom, not at the 100 % width.
  • Ctrl+wheel notches already queued behind the first one are taken together, so a fast spin lays the rows out once, at the level it ends on. Paints are coalesced by Windows as before, so a held Ctrl+Plus paints once per repeat the window can keep up with.
  • Background tabs get their widths rescaled from their own basis.

Choices considered

Option Why not, or why
Cache fonts per level Fonts cost 0.15 ms per step. Nothing to win.
Scale a bitmap of the old rows while the step runs Blurry text at every level, and it still needs the real layout after.
Defer the step to a timer or idle Adds latency to every single step to help only bursts.
Redraw off around the change, invalidate only, let Windows coalesce paints, join queued wheel notches, keep a width basis Chosen. It removes the synchronous repaints, the whole-window repaint and the drift, and needs no new state beyond one width basis per column.

Measurements

Same machine, same harness (tools/Measure-LeanRowsZoom.ps1), main d323dbf and this branch, 3 runs each, 1400 x 900 window, saved zoom set to 100 % for the run. A step is timed from the command until the list shows the new level and has painted it (no update region left on the list or its titles, and a no-op message to the list answered).

File (rows, columns) Measure main branch
small-1000.csv (1,001, 6) one step, median / p90 / max 267.3 / 355.2 / 423.0 ms 24.8 / 32.2 / 37.4 ms
8 Ctrl+wheel notches at once, until painted 1,139 ms 19.9 ms
12 notches down at once 3,030 ms 44.7 ms
Ctrl+Plus held 8 repeats (33 ms apart), after the last 947 ms 0.6 ms
six-2mb.csv (48,601, 6) one step 264.5 / 364.4 / 438.4 ms 25.8 / 32.7 / 38.4 ms
8 notches / 12 notches / held key 1,153 / 3,125 / 951 ms 19.3 / 40.7 / 0.7 ms
shuffled-10000000.csv (10M, 2) one step 97.8 / 118.1 / 136.8 ms 17.6 / 21.1 / 23.1 ms
8 notches / 12 notches / held key 581 / 1,073 / 359 ms 19.4 / 31.2 / 0.8 ms
wide-200cols.csv (2,000, 64), 1 run one step 2,039 / 4,345 / 6,793 ms 151 / 205 / 254 ms
8 notches / 12 notches / held key 7,688 / 31,547 / 7,583 ms 84 / 263 / 52 ms
long-fields.csv (2,000), 1 run one step 372 / 469 / 545 ms 47.4 / 61.3 / 73.3 ms

A step's time now follows how many cells are on screen: on the 2 MB file it is 37.5 ms at 50 % and 16.9 ms at 300 % (medians per level). Timing inside the app (document smoke on the 2 MB file, 943 x 535 rows area, 9 steps) splits it into the step itself, 6.0 to 8.9 ms, and the list's own paint, 11.8 to 22.6 ms. On the wide file the step is 7.9 to 9.9 ms and the paint 103 to 170 ms, because the list asks for every column of every visible row (1,755 cells at 100 %), not only the columns on screen.

Memory and resources, same runs:

main branch
Working set at rest / peak while zooming / after 18.3 / 18.5 / 18.5 MiB 18.3 / 18.5 / 18.5 MiB
Private bytes at rest / peak while zooming 3.3 / 3.5 MiB 3.3 / 3.4 MiB
GDI objects before / after the run 51 / 53 51 / 53

Everything else, measured the same way on both builds (tools/Compare-LeanRowsBuilds.ps1 with 7 starts, and a probe that opens the 10M-row file, pages down and wheels 300 times each, sorts column 1 and finds a value near the end; 3 alternating runs, medians):

main branch
Start to input idle, no file 262 ms (257 to 451) 265 ms (260 to 414)
Start to input idle, 2 MB file 323 ms (317 to 335) 337 ms (323 to 357)
Open 10M rows until the row count settles 3,849 ms 3,702 ms
Page Down, flushed 20.4 ms 20.6 ms
One wheel notch, flushed 2.2 ms 2.7 ms
Find, value near the end 11.8 s 12.2 s
Sort column 1, text 59.0 s (24.7 to 67.2) 25.6 s (24.6 to 79.3)
Peak working set during sort 24.9 MiB 24.9 MiB
Executable 986,112 bytes 1,001,472 bytes

The start, wheel and sort differences are inside the run-to-run spread (an earlier start comparison gave 334 ms on main and 327 ms on the branch). Sort time is dominated by disk and varies 3x between runs on both builds; no sort code changed. The executable grows by 15,360 bytes: 7,680 for the first commit's smoke checks, 6,656 for the change (bb01d9e) and 1,024 for the later checks. The smoke checks ship in the executable because CI runs them against the release build.

Tests

Commit 6182106 adds the checks before the change. On main they fail with:

  • at 125% the columns are [139, 900] px, not [140, 900] as when opened there
  • a zoom step painted the rows before it returned
  • a background tab zoomed to 125% kept [139, 900] px and showed [139, 900], not [140, 900]
  • document smoke: a zoom step painted 50 cells before it returned (20 for the .log fixture)

Later commits add a check that three queued Ctrl+wheel notches are one change to 150 %, that the titles' height follows their font at every level, and that the titles get the zoomed font.

Mutation table

Each row is one change to the branch, then cargo test -p leanrows-win32 --lib zoom and the release build's --smoke-test and --document-smoke-test (both fixtures), as CI runs them. The first failing line is quoted. Each mutation was reverted afterwards and the suite is green again (264 workspace tests, clippy with -D warnings, smokes at saved zoom 50, 100, 125 and 300 %).

# Mutation First failing line
M1 delete WM_SETREDRAW(0) in with_list_redraw_off smoke: a zoom step painted the rows before it returned
M2 UpdateWindow(list) at the end of the step smoke: a zoom step painted the rows before it returned
M3 main's redraw_shell_children(window) at the end of the step smoke: a zoom step painted the rows before it returned
M4 rescale the width shown instead of the basis (main's compounding) unit: a_zoomed_column_is_as_wide_as_one_opened_at_that_level (zoom.rs:305)
M5 insert columns at the design width whatever the zoom unit: a_tab_shown_again_carries_on_from_its_widths (zoom.rs:338)
M6 skip the background tabs' widths smoke: a background tab zoomed to 125% kept [112, 720] px and showed [112, 720], not [140, 900]
M7 delete the queued-notch zoom update smoke: three queued notches from 100% gave 110% with 0 left queued, not 150%
M8 do not give the list the zoomed font smoke: the rows' font is not 110% of the body font
M9 do not give the header the zoomed font smoke: the column titles do not have the 110% title font
M10 leave the list's redraw off after the step smoke: at 150% the column titles are 24 px high, not the 29 px their font needs
M11 do not save the level smoke: zoom command did not apply and save 110%

M4 is caught by the unit test only. On the smoke's path (100 -> 300 -> 50 -> 100 %) compounding happens to round back to the same widths.

Two lines an earlier version had were deleted because removing them left every check green: a one-pixel resize of the list to refit the header, and a RedrawWindow of the list after the step. Turning redraw back on already lays out the header for the new font and invalidates the list and header. Timing inside the app agrees: with the resize back the step takes 10.1 to 13.0 ms instead of 6.0 to 8.9 ms, and the paint after it is the same.

Other checks run by hand on the branch build:

  • Zoom during a 10M-row sort: five steps while the sort ran gave widths 123/242, 140/275, 168/330, 140/275, 168/330 px; the sort finished (Ready | 10000000 rows | column 1 / Text / Ascending) with the widths kept.
  • Open after zoom: opening the 2 MB file into the same window at 150 % gave columns of 168 and 330 px, the same as starting at 150 %.

Back-compat and rollback

  • No saved data changes. The saved zoom level is read and written as before.
  • Reverting the merge commit restores the old step. Nothing written by this build needs migrating.
  • This merge runs the CI workflow only. No release is cut from it.

Not touched

  • header.rs:169 paints the column titles with fonts.semibold(), so the titles' text does not zoom although the header gets taller. Separate change.
  • restore_tab sets the widths with redraw on, so switching tabs still repaints once per column.
  • Default column widths are not scaled for DPI, and widths do not follow a DPI change.
  • The list's own paint is 12 to 22 ms in this window (Page Down costs about the same). It is now most of a zoom step.

Known gaps

  • The target was under 16 ms per step. On this laptop a step is 24.8 to 25.8 ms (median) on the six-column files and 17.6 ms on the two-column 10M file. What is left is one paint of the list, which costs the same as a Page Down (about 20 ms here). Getting under 16 ms needs a faster list paint, which would also speed up scrolling. That is a separate change.
  • On a 64-column file a step is 151 ms (2,039 ms on main), almost all list paint, because the list asks for every column of every visible row.
  • High contrast and a DPI change were not exercised: both need system settings changed. The step does not touch colours, and the DPI path is unchanged.
  • The steps are driven with WM_COMMAND and posted wheel messages, not real input.

The shell smoke now zooms 100% to 300%, down to 50% and back, and checks
that each column is as wide as one opened at that level and that the
widths at the end equal the widths at the start. It also checks that a
zoom step leaves the rows to repaint and does not invalidate the toolbar.
The document smoke checks, with real rows on screen, that a zoom step asks
the list for no cell before it returns and that the rows repaint after it.

On main these fail: at 125% the columns are 139 and 900 px instead of
140 and 900, and a step paints before it returns.

tools/Measure-LeanRowsZoom.ps1 times a zoom step and three kinds of burst
from outside the process, with the working set sampled every 2 ms.
A zoom step rebuilt every font and brush, gave every control its font
again, set each column width with the list's redraw on, resized the list
twice, laid out the whole window and repainted all of it before it
returned. Each LVM_SETCOLUMNWIDTH with redraw on repaints every visible
row, so a six-column file paid seven full repaints per step: 176 of the
241 ms a step took.

Now a step creates the two grid fonts, gives them to the list and its
header, sets the widths and refits the header with the list's redraw
off, and only invalidates the list. Windows paints it once when the
message queue is empty, so queued steps paint once. Ctrl+wheel notches
already queued behind the first one join it, so a fast spin lays the
rows out once, at the level it ends on.

Column widths now scale from a per-column basis instead of from the
width shown before the step. Stepping 100% to 300% to 50% and back used
to shrink the Row column from 112 to 102 px; now it returns to 112, and
a column at a level is as wide as one opened there. Columns inserted
while zoomed, including at start with a saved zoom, are scaled too.
Three Ctrl+wheel notches posted to the list are one change from 100% to
125%, and no notch is left in the queue after the first is handled.
Turning the list's redraw back on lays out the list and its header for
the new font and marks both for repainting, so the two one-pixel resizes
of the list and the explicit RedrawWindow did nothing more. Mutation runs
showed both could be deleted with every check green; they are gone.

The smoke now checks, at every level, that the column titles are as high
as HDM_LAYOUT says their font needs, that they are higher at 300% than at
50%, and that a step leaves them to repaint.

Switching tabs keeps setting column widths with redraw on, as on main.
A mutation run showed that deleting the header's WM_SETFONT left every
check green, because the list hands its own font to the header. The smoke
now checks that the header holds the zoomed title font at every step.
The measurement waited for the zoomed layout and then asked the window to
paint whatever was pending. It now also waits until the list and its column
titles have no update region and a no-op message to the list has been
answered, so a step's time includes the paint that shows it.
@abooodbah
abooodbah merged commit 95146aa into main Nov 6, 2026
4 checks passed
@abooodbah
abooodbah deleted the perf/zoom-step branch November 7, 2026 15:58
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