From 6182106b79800ab304a1c9070426dd4faf1dece6 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:14:37 -0600 Subject: [PATCH 1/6] Add zoom checks that fail on the current zoom step 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. --- crates/leanrows-win32/src/native.rs | 216 +++++++++++++++++++++++++ tools/Measure-LeanRowsZoom.ps1 | 235 ++++++++++++++++++++++++++++ tools/README.md | 12 ++ 3 files changed, 463 insertions(+) create mode 100644 tools/Measure-LeanRowsZoom.ps1 diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 4b7a331..6a25298 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -277,6 +277,9 @@ struct WindowState { closing: bool, document_smoke: bool, document_smoke_observed: Arc, + /// Cells the list asked for during the document smoke, so it can tell + /// whether a zoom step painted rows before it returned. + document_smoke_cells: AtomicU64, document_smoke_evidence: Arc>>, reclaimed: Arc, } @@ -586,6 +589,7 @@ pub(crate) fn run(options: ShellOptions) -> Result { closing: false, document_smoke: options.document_smoke_test, document_smoke_observed: Arc::clone(&document_smoke_observed), + document_smoke_cells: AtomicU64::new(0), document_smoke_evidence: Arc::clone(&document_smoke_evidence), reclaimed: Arc::clone(&reclaimed), }); @@ -1156,6 +1160,9 @@ fn verify_grid_zoom(window: HWND) -> Result<(), ShellError> { .zoom; let verification = (|| { set_grid_zoom(window, crate::zoom::DEFAULT_ZOOM)?; + verify_zoom_column_widths(window)?; + verify_zoom_repaints_only_rows(window)?; + verify_zoom_background_tab(window)?; for (command, expected) in [ (ID_VIEW_ZOOM_IN, 110), (ID_VIEW_ZOOM_IN, 125), @@ -1206,6 +1213,208 @@ fn verify_grid_zoom(window: HWND) -> Result<(), ShellError> { verification } +/// Zooming 100% to 300%, down to 50% and back gives the widths it started +/// with, and at every level each column is as wide as one opened at that +/// level: the Row column 112 px and the Preview column 720 px at 100%. +fn verify_zoom_column_widths(window: HWND) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + let widths = || { + // SAFETY: Smoke runs synchronously on the window's owning UI thread. + unsafe { pointer.as_ref() } + .map(|state| column_widths(state.list, state.columns)) + .ok_or_else(|| ShellError::new("zoom width smoke state was detached")) + }; + let start = widths()?; + if start != [112, 720] { + return Err(ShellError::new(format!( + "zoom width smoke expected Row and Preview at 112 and 720 px, found {start:?}" + ))); + } + let up = crate::zoom::ZOOM_LEVELS + .iter() + .filter(|level| **level > 100); + let down = crate::zoom::ZOOM_LEVELS.iter().rev().skip(1); + let back = crate::zoom::ZOOM_LEVELS + .iter() + .filter(|level| **level > 50 && **level <= 100); + let mut previous = crate::zoom::DEFAULT_ZOOM; + for &level in up.chain(down).chain(back) { + let command = if level > previous { + ID_VIEW_ZOOM_IN + } else { + ID_VIEW_ZOOM_OUT + }; + // SAFETY: The command is routed synchronously through the window. + unsafe { + SendMessageW( + window, + WM_COMMAND, + Some(WPARAM(usize::from(command))), + Some(LPARAM(0)), + ); + } + previous = level; + let cold = [ + crate::zoom::scale_by_zoom(112, level), + crate::zoom::scale_by_zoom(720, level), + ]; + let shown = widths()?; + if shown != cold { + return Err(ShellError::new(format!( + "at {level}% the columns are {shown:?} px, not {cold:?} as when opened there" + ))); + } + } + if widths()? != start { + return Err(ShellError::new( + "zooming out and back did not return the starting column widths", + )); + } + Ok(()) +} + +/// The next level in, or the next level out from the largest one. +fn other_zoom_level(zoom: u32) -> u32 { + let next = crate::zoom::step_zoom(zoom, 1); + if next == zoom { + crate::zoom::step_zoom(zoom, -1) + } else { + next + } +} + +/// A zoom step changes only the rows, so it leaves only the list to repaint, +/// and it paints nothing before it returns: the paint comes once, after the +/// step and any steps queued behind it. +fn verify_zoom_repaints_only_rows(window: HWND) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + // SAFETY: Smoke runs synchronously on the window's owning UI thread. + let (list, topbar, original) = unsafe { pointer.as_ref() } + .map(|state| (state.list, state.chrome.topbar, state.theme.zoom)) + .ok_or_else(|| ShellError::new("zoom paint smoke state is unavailable"))?; + // SAFETY: Painting what is pending clears every update region first. + unsafe { + let _ = RedrawWindow( + Some(window), + None, + None, + RDW_UPDATENOW | windows::Win32::Graphics::Gdi::RDW_ALLCHILDREN, + ); + } + let result = set_grid_zoom(window, other_zoom_level(original)); + // SAFETY: Both are live children; a null rectangle only asks whether + // part of the window waits to be painted. + let (rows_pending, bar_pending) = unsafe { + ( + windows::Win32::Graphics::Gdi::GetUpdateRect(list, None, false).as_bool(), + windows::Win32::Graphics::Gdi::GetUpdateRect(topbar, None, false).as_bool(), + ) + }; + let restore = set_grid_zoom(window, original); + result?; + restore?; + if !rows_pending { + return Err(ShellError::new( + "a zoom step painted the rows before it returned", + )); + } + if bar_pending { + return Err(ShellError::new("a zoom step invalidated the toolbar")); + } + Ok(()) +} + +/// A tab in the background zooms with the one showing. After two steps to +/// 125% its columns are 140 and 900 px, as when opened at 125%, and the list +/// shows those widths when the tab comes back. +fn verify_zoom_background_tab(window: HWND) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + // SAFETY: Smoke runs synchronously on the window's owning UI thread. + let state = unsafe { pointer.as_mut() } + .ok_or_else(|| ShellError::new("zoom tab smoke state is unavailable"))?; + let tab = stash_active(state)?; + state.background_tabs.push(tab); + for _ in 0..2 { + // SAFETY: The command is routed synchronously through the window. + unsafe { + SendMessageW( + window, + WM_COMMAND, + Some(WPARAM(usize::from(ID_VIEW_ZOOM_IN))), + Some(LPARAM(0)), + ); + } + } + // SAFETY: As above; the zoom commands leave the state attached. + let state = unsafe { pointer.as_mut() } + .ok_or_else(|| ShellError::new("zoom tab smoke state was detached"))?; + let tab = state + .background_tabs + .pop() + .ok_or_else(|| ShellError::new("zoom tab smoke lost its background tab"))?; + let stored = tab.column_widths.clone(); + let restored = restore_tab(window, state, tab); + let shown = column_widths(state.list, state.columns); + let back = set_grid_zoom(window, crate::zoom::DEFAULT_ZOOM); + restored?; + back?; + let cold = [ + crate::zoom::scale_by_zoom(112, 125), + crate::zoom::scale_by_zoom(720, 125), + ]; + if stored != cold || shown != cold { + return Err(ShellError::new(format!( + "a background tab zoomed to 125% kept {stored:?} px and showed {shown:?}, not {cold:?}" + ))); + } + Ok(()) +} + +/// With rows on screen, a zoom step asks the list for no cell before it +/// returns, and the list paints its rows once afterwards. +fn verify_zoom_with_rows(window: HWND) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + let cells = || { + // SAFETY: The document smoke runs on the window's owning UI thread. + unsafe { pointer.as_ref() } + .map(|state| state.document_smoke_cells.load(Ordering::Relaxed)) + .unwrap_or_default() + }; + // SAFETY: As above; only handles and the level are copied. + let (list, original) = unsafe { pointer.as_ref() } + .map(|state| (state.list, state.theme.zoom)) + .ok_or_else(|| ShellError::new("zoom row smoke state is unavailable"))?; + // SAFETY: Painting what is pending clears every update region first. + unsafe { + let _ = RedrawWindow( + Some(window), + None, + None, + RDW_UPDATENOW | windows::Win32::Graphics::Gdi::RDW_ALLCHILDREN, + ); + } + let before = cells(); + let result = set_grid_zoom(window, other_zoom_level(original)); + let during = cells().saturating_sub(before); + // SAFETY: The list is live; this paints what the step left pending. + let _ = unsafe { UpdateWindow(list) }; + let after = cells().saturating_sub(before).saturating_sub(during); + let restore = set_grid_zoom(window, original); + result?; + restore?; + if during != 0 { + return Err(ShellError::new(format!( + "a zoom step painted {during} cells before it returned" + ))); + } + if after == 0 { + return Err(ShellError::new( + "the rows did not repaint after a zoom step", + )); + } + Ok(()) +} + /// The settings panel opens from its command, takes the right edge, focuses /// the chosen theme, lists every shortcut, changes theme and zoom, and closes /// on Escape with the rows back at full width. @@ -5219,6 +5428,9 @@ fn serve_display_text(state: &WindowState, notification: &mut NMLVDISPINFOW) { // immutable cache storage or the static loading string. Smoke attestation is // one bounded comparison plus an atomic store. notification.item.pszText = PWSTR(text.as_ptr().cast_mut()); + if state.document_smoke { + state.document_smoke_cells.fetch_add(1, Ordering::Relaxed); + } if state.document_smoke && state.active_serial.is_some() && absolute_row == 0 @@ -6176,6 +6388,10 @@ fn apply_document_event(window: HWND, event: &WorkerEvent) -> bool { if state.document_smoke_observed.load(Ordering::Acquire) & required_mask != required_mask { return true; } + if let Err(error) = verify_zoom_with_rows(window) { + eprintln!("document smoke zoom check failed: {error}"); + return true; + } let Some(evidence) = document_smoke_evidence(event) else { return true; }; diff --git a/tools/Measure-LeanRowsZoom.ps1 b/tools/Measure-LeanRowsZoom.ps1 new file mode 100644 index 0000000..024330b --- /dev/null +++ b/tools/Measure-LeanRowsZoom.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BinaryPath, + [Parameter(Mandatory = $true)][string]$DocumentPath, + [int]$Runs = 3, + [int]$LoadSeconds = 4, + [string]$Label = '', + [string]$OutJson +) +# Drives a running LeanRows window from outside and times zoom. The saved +# zoom level is set to 100% for the run and put back afterwards. +# One step: WM_COMMAND zoom in/out, then wait until the list shows the state a +# settled window has at that level (header height and row height), then flush +# any pending paint with RedrawWindow(RDW_UPDATENOW | RDW_ALLCHILDREN). +# Bursts post the input at once (Ctrl+wheel notches) or every 33 ms (a held +# Ctrl+Plus at the keyboard repeat rate) and time until the final level is +# shown and painted. A sampler thread records the working set every 2 ms. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +if (-not ('ZoomProbe' -as [type])) { +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class ZoomProbe { + [DllImport("user32.dll")] public static extern IntPtr SendMessageW(IntPtr h, uint m, IntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool PostMessageW(IntPtr h, uint m, IntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool RedrawWindow(IntPtr h, IntPtr r, IntPtr g, uint f); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr h, EnumProc p, IntPtr l); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] public static extern uint GetGuiResources(IntPtr p, uint f); + public delegate bool EnumProc(IntPtr h, IntPtr l); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + + public const uint WM_COMMAND = 0x0111, WM_MOUSEWHEEL = 0x020A; + const uint LVM_GETHEADER = 0x101F, LVM_GETCOUNTPERPAGE = 0x1028, LVM_GETITEMCOUNT = 0x1004; + const uint RDW_UPDATENOW = 0x100, RDW_ALLCHILDREN = 0x80; + + public static IntPtr FindList(IntPtr window) { + IntPtr found = IntPtr.Zero; + EnumChildWindows(window, (h, l) => { + var s = new StringBuilder(64); GetClassNameW(h, s, 64); + if (s.ToString() == "SysListView32" && found == IntPtr.Zero) { found = h; } + return true; + }, IntPtr.Zero); + return found; + } + public static int ItemCount(IntPtr list) { return (int)SendMessageW(list, LVM_GETITEMCOUNT, IntPtr.Zero, IntPtr.Zero); } + // Header height and rows per page identify the zoom a settled list shows. + public static long State(IntPtr list) { + IntPtr header = SendMessageW(list, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero); + RECT r; GetWindowRect(header, out r); + // LVM_APPROXIMATEVIEWRECT for 500 rows: its height gives the row height. + long view = (long)SendMessageW(list, 0x1040, (IntPtr)500, (IntPtr)(-1)); + int viewHeight = (int)((view >> 16) & 0xffff); + return ((long)(r.Bottom - r.Top) << 32) | (uint)viewHeight; + } + public static void Flush(IntPtr window) { RedrawWindow(window, IntPtr.Zero, IntPtr.Zero, RDW_UPDATENOW | RDW_ALLCHILDREN); } + public static void Command(IntPtr window, int id) { SendMessageW(window, WM_COMMAND, (IntPtr)id, IntPtr.Zero); } + public static void PostCommand(IntPtr window, int id) { PostMessageW(window, WM_COMMAND, (IntPtr)id, IntPtr.Zero); } + public static void PostWheel(IntPtr list, int notches) { + int delta = 120 * notches; + long w = ((long)(delta & 0xffff) << 16) | 0x0008; + PostMessageW(list, WM_MOUSEWHEEL, (IntPtr)w, IntPtr.Zero); + } + // Waits until the list shows `expected`, then flushes paint. Returns ms. + public static double WaitFor(IntPtr window, IntPtr list, long expected, Stopwatch clock, int timeoutMs) { + while (clock.ElapsedMilliseconds < timeoutMs) { + if (State(list) == expected) { Flush(window); return clock.Elapsed.TotalMilliseconds; } + Thread.Sleep(0); + } + return -1; + } + + // Working-set sampler. + static volatile bool sampling; + static long peakWs, peakPrivate; + static Thread sampler; + public static void StartSampling(Process p) { + peakWs = 0; peakPrivate = 0; sampling = true; + sampler = new Thread(() => { + while (sampling) { + p.Refresh(); + long ws = p.WorkingSet64, pv = p.PrivateMemorySize64; + if (ws > peakWs) peakWs = ws; + if (pv > peakPrivate) peakPrivate = pv; + Thread.Sleep(2); + } + }); + sampler.IsBackground = true; sampler.Start(); + } + public static long[] StopSampling() { sampling = false; sampler.Join(); return new long[] { peakWs, peakPrivate }; } + public static uint Gdi(Process p) { return GetGuiResources(p.Handle, 0); } +} +'@ +} + +$IdIn = 123; $IdOut = 124; $IdReset = 125 +$levels = @(50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300) + +$regPath = 'HKCU:\Software\LeanRows' +$savedZoom = (Get-ItemProperty -Path $regPath -Name ZoomPercent -ErrorAction SilentlyContinue) +if ($savedZoom) { $savedZoom = $savedZoom.ZoomPercent } +New-Item -Path $regPath -Force | Out-Null +Set-ItemProperty -Path $regPath -Name ZoomPercent -Value 100 -Type DWord + +$process = Start-Process -FilePath $BinaryPath -ArgumentList @('--', $DocumentPath) -PassThru +try { + $null = $process.WaitForInputIdle(30000) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ($process.MainWindowHandle -eq [IntPtr]::Zero -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 50; $process.Refresh() + } + $window = $process.MainWindowHandle + [void][ZoomProbe]::SetWindowPos($window, [IntPtr]::Zero, 40, 40, 1400, 900, 0x0014) + $list = [ZoomProbe]::FindList($window) + if ($list -eq [IntPtr]::Zero) { throw 'list not found' } + # Wait until the row count stops growing. + $last = -1; $stable = 0 + while ($stable -lt 10) { + Start-Sleep -Milliseconds 200 + $count = [ZoomProbe]::ItemCount($list) + if ($count -eq $last -and $count -gt 0) { $stable++ } else { $stable = 0 } + $last = $count + } + Start-Sleep -Seconds $LoadSeconds + $process.Refresh() + $restWs = $process.WorkingSet64; $restPrivate = $process.PrivateMemorySize64 + $gdiBefore = [ZoomProbe]::Gdi($process) + + # Reference state for each level, after a settled step. + [ZoomProbe]::Command($window, $IdReset); Start-Sleep -Milliseconds 300 + $reference = @{} + $reference[100] = [ZoomProbe]::State($list) + $zoom = 100 + while ($zoom -lt 300) { + [ZoomProbe]::Command($window, $IdIn); Start-Sleep -Milliseconds 300 + $zoom = ($levels | Where-Object { $_ -gt $zoom } | Select-Object -First 1) + $reference[$zoom] = [ZoomProbe]::State($list) + } + while ($zoom -gt 50) { + [ZoomProbe]::Command($window, $IdOut); Start-Sleep -Milliseconds 300 + $zoom = ($levels | Where-Object { $_ -lt $zoom } | Select-Object -Last 1) + $reference[$zoom] = [ZoomProbe]::State($list) + } + [ZoomProbe]::Command($window, $IdReset); Start-Sleep -Milliseconds 300 + + [ZoomProbe]::StartSampling($process) + $steps = New-Object System.Collections.Generic.List[double] + $stepsByLevel = @{} + $bursts = @{ wheel8 = (New-Object System.Collections.Generic.List[double]); key8 = (New-Object System.Collections.Generic.List[double]); wheel_down12 = (New-Object System.Collections.Generic.List[double]) } + for ($run = 0; $run -lt $Runs; $run++) { + # Single steps 100 -> 300 -> 50 -> 100. + $zoom = 100 + $plan = @() + foreach ($l in $levels) { if ($l -gt 100) { $plan += , @($IdIn, $l) } } + foreach ($l in ($levels[($levels.Length - 2)..0])) { $plan += , @($IdOut, $l) } + foreach ($l in $levels) { if ($l -gt 50 -and $l -le 100) { $plan += , @($IdIn, $l) } } + foreach ($step in $plan) { + $clock = [System.Diagnostics.Stopwatch]::StartNew() + [ZoomProbe]::Command($window, $step[0]) + $ms = [ZoomProbe]::WaitFor($window, $list, $reference[$step[1]], $clock, 10000) + $steps.Add($ms) + $key = "$($step[1])" + if (-not $stepsByLevel.ContainsKey($key)) { $stepsByLevel[$key] = New-Object System.Collections.Generic.List[double] } + $stepsByLevel[$key].Add($ms) + Start-Sleep -Milliseconds 60 + } + # Burst: eight Ctrl+wheel notches at once, 100 -> 300. + Start-Sleep -Milliseconds 200 + $clock = [System.Diagnostics.Stopwatch]::StartNew() + for ($i = 0; $i -lt 8; $i++) { [ZoomProbe]::PostWheel($list, 1) } + $bursts.wheel8.Add([ZoomProbe]::WaitFor($window, $list, $reference[300], $clock, 20000)) + Start-Sleep -Milliseconds 200 + # Burst: twelve notches down at once, 300 -> 50. + $clock = [System.Diagnostics.Stopwatch]::StartNew() + for ($i = 0; $i -lt 12; $i++) { [ZoomProbe]::PostWheel($list, -1) } + $bursts.wheel_down12.Add([ZoomProbe]::WaitFor($window, $list, $reference[50], $clock, 20000)) + Start-Sleep -Milliseconds 200 + [ZoomProbe]::Command($window, $IdReset); Start-Sleep -Milliseconds 200 + # Held Ctrl+Plus: eight zoom-in commands 33 ms apart; time from the + # last key to the final level painted. + $clock = [System.Diagnostics.Stopwatch]::StartNew() + for ($i = 0; $i -lt 8; $i++) { + [ZoomProbe]::PostCommand($window, $IdIn) + if ($i -lt 7) { $target = $clock.ElapsedMilliseconds + 33; while ($clock.ElapsedMilliseconds -lt $target) { } } + } + $lastKey = $clock.Elapsed.TotalMilliseconds + $done = [ZoomProbe]::WaitFor($window, $list, $reference[300], $clock, 20000) + $bursts.key8.Add($done - $lastKey) + Start-Sleep -Milliseconds 200 + [ZoomProbe]::Command($window, $IdReset); Start-Sleep -Milliseconds 200 + } + $peak = [ZoomProbe]::StopSampling() + $process.Refresh() + $afterWs = $process.WorkingSet64 + $gdiAfter = [ZoomProbe]::Gdi($process) + + function Stat($values) { + $sorted = @($values | Sort-Object) + $n = $sorted.Count + [pscustomobject]@{ n = $n; median = [math]::Round($sorted[[int][math]::Floor($n / 2)], 1); p90 = [math]::Round($sorted[[int][math]::Min($n - 1, [math]::Floor($n * 0.9))], 1); max = [math]::Round($sorted[$n - 1], 1); min = [math]::Round($sorted[0], 1) } + } + $levelStats = [ordered]@{} + foreach ($l in $levels) { if ($stepsByLevel.ContainsKey("$l")) { $levelStats["$l"] = (Stat $stepsByLevel["$l"]).median } } + $result = [pscustomobject]@{ + label = $Label + file = [System.IO.Path]::GetFileName($DocumentPath) + rows = [ZoomProbe]::ItemCount($list) + step_ms = (Stat $steps) + step_median_by_level = $levelStats + wheel8_settle_ms = (Stat $bursts.wheel8) + wheel_down12_settle_ms = (Stat $bursts.wheel_down12) + held_key8_after_last_key_ms = (Stat $bursts.key8) + rest_ws_mib = [math]::Round($restWs / 1MB, 1) + rest_private_mib = [math]::Round($restPrivate / 1MB, 1) + zoom_peak_ws_mib = [math]::Round($peak[0] / 1MB, 1) + zoom_peak_private_mib = [math]::Round($peak[1] / 1MB, 1) + after_ws_mib = [math]::Round($afterWs / 1MB, 1) + gdi_before = $gdiBefore + gdi_after = $gdiAfter + } + if ($OutJson) { $result | ConvertTo-Json -Depth 5 | Set-Content -Path $OutJson -Encoding utf8 } + $result | ConvertTo-Json -Depth 5 +} finally { + if (-not $process.HasExited) { $process.CloseMainWindow() | Out-Null; if (-not $process.WaitForExit(5000)) { $process.Kill() } } + if ($savedZoom) { Set-ItemProperty -Path $regPath -Name ZoomPercent -Value $savedZoom -Type DWord } +} diff --git a/tools/README.md b/tools/README.md index bef6e3d..cebffce 100644 --- a/tools/README.md +++ b/tools/README.md @@ -11,6 +11,18 @@ that file through the core engine (`leanrows-spike --sort`) and through the app's worker (the ignored test `sort_benchmark_from_environment`). The table is in the job log and the run summary. +## Zoom measurements + +`tools/Measure-LeanRowsZoom.ps1 -BinaryPath target/release/leanrows.exe +-DocumentPath FILE` opens FILE, then times zoom from outside the process. A +step is a zoom command until the list shows the header and row height a +settled window has at that level, with any pending paint flushed. It also +times eight Ctrl+wheel notches posted at once, twelve notches down at once, +and eight zoom-in commands 33 ms apart (a held Ctrl+Plus), and samples the +working set every 2 ms while it zooms. The saved zoom level is set to 100% +for the run and put back afterwards. Run main and a branch the same way, one +after the other, on an otherwise idle machine. + ## External sort measurements `rtk proxy pwsh -NoProfile -File tools/Measure-LeanRowsSort.ps1` writes nonsparse From bb01d9e5d35cce104ee6762357a2ef02e0755cfd Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:30:20 -0600 Subject: [PATCH 2/6] Make a zoom step change only the rows and paint once 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. --- crates/leanrows-win32/src/native.rs | 297 +++++++++++++++------- crates/leanrows-win32/src/native/theme.rs | 74 +++++- crates/leanrows-win32/src/zoom.rs | 248 +++++++++++++++++- 3 files changed, 520 insertions(+), 99 deletions(-) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 6a25298..370d8aa 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -26,10 +26,10 @@ use windows::Win32::Graphics::Gdi::{ BS_SOLID, COLOR_WINDOW, CombineRgn, CreateEllipticRgn, CreateRectRgn, CreateRoundRectRgn, DT_CENTER, DT_END_ELLIPSIS, DT_LEFT, DT_SINGLELINE, DT_VCENTER, DeleteObject, DrawFocusRect, DrawTextW, Ellipse, ExtCreatePen, FillRect, FillRgn, FrameRgn, GetDC, GetPixel, GetStockObject, - GetSysColorBrush, GetTextExtentPoint32W, HBRUSH, HDC, HGDIOBJ, InvalidateRect, LOGBRUSH, - LineTo, MoveToEx, NULL_BRUSH, PS_ENDCAP_ROUND, PS_GEOMETRIC, PS_SOLID, RDW_ALLCHILDREN, - RDW_ERASE, RDW_INVALIDATE, RDW_UPDATENOW, RGN_AND, RedrawWindow, ReleaseDC, SelectObject, - SetBkColor, SetBkMode, SetTextColor, TRANSPARENT, UpdateWindow, + GetSysColorBrush, GetTextExtentPoint32W, GetUpdateRect, HBRUSH, HDC, HGDIOBJ, InvalidateRect, + LOGBRUSH, LineTo, MoveToEx, NULL_BRUSH, PS_ENDCAP_ROUND, PS_GEOMETRIC, PS_SOLID, + RDW_ALLCHILDREN, RDW_ERASE, RDW_FRAME, RDW_INVALIDATE, RDW_UPDATENOW, RGN_AND, RedrawWindow, + ReleaseDC, SelectObject, SetBkColor, SetBkMode, SetTextColor, TRANSPARENT, UpdateWindow, }; use windows::Win32::System::LibraryLoader::{FindResourceW, GetModuleHandleW}; use windows::Win32::System::SystemServices::{ @@ -73,19 +73,19 @@ use windows::Win32::UI::WindowsAndMessaging::{ IDCANCEL, IDOK, IMAGE_ICON, IsIconic, IsWindow, IsWindowVisible, KillTimer, LR_SHARED, LoadAcceleratorsW, LoadCursorW, LoadImageW, MB_ICONERROR, MB_ICONINFORMATION, MB_ICONWARNING, MB_OK, MB_TASKMODAL, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MF_UNCHECKED, MINMAXINFO, - MSG, MessageBoxW, MoveWindow, PostMessageW, PostQuitMessage, RT_DIALOG, RegisterClassExW, - SIZE_MINIMIZED, SM_CXICON, SM_CXSMICON, SM_CYICON, SM_CYSMICON, SW_HIDE, SW_RESTORE, SW_SHOW, - SW_SHOWDEFAULT, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SendMessageW, - SetDlgItemTextW, SetForegroundWindow, SetTimer, SetWindowLongPtrW, SetWindowPos, - SetWindowTextW, ShowWindow, TPM_RETURNCMD, TPM_RIGHTALIGN, TrackPopupMenuEx, - TranslateAcceleratorW, TranslateMessage, WINDOW_EX_STYLE, WINDOW_LONG_PTR_INDEX, WINDOW_STYLE, - WM_APP, WM_CLOSE, WM_COMMAND, WM_COPYDATA, WM_CREATE, WM_CTLCOLORBTN, WM_CTLCOLOREDIT, - WM_CTLCOLORSTATIC, WM_DESTROY, WM_DPICHANGED, WM_DRAWITEM, WM_DROPFILES, WM_ERASEBKGND, - WM_GETFONT, WM_GETMINMAXINFO, WM_INITDIALOG, WM_KEYDOWN, WM_MOUSEWHEEL, WM_NCCREATE, - WM_NCDESTROY, WM_NOTIFY, WM_PRINTCLIENT, WM_SETFONT, WM_SETTINGCHANGE, WM_SIZE, - WM_SYSCOLORCHANGE, WM_THEMECHANGED, WM_TIMER, WNDCLASSEXW, WS_CHILD, WS_CLIPCHILDREN, - WS_CLIPSIBLINGS, WS_EX_ACCEPTFILES, WS_EX_APPWINDOW, WS_EX_TOPMOST, WS_OVERLAPPEDWINDOW, - WS_POPUP, WS_TABSTOP, + MSG, MessageBoxW, MoveWindow, PM_NOREMOVE, PM_REMOVE, PeekMessageW, PostMessageW, + PostQuitMessage, RT_DIALOG, RegisterClassExW, SIZE_MINIMIZED, SM_CXICON, SM_CXSMICON, + SM_CYICON, SM_CYSMICON, SW_HIDE, SW_RESTORE, SW_SHOW, SW_SHOWDEFAULT, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SendMessageW, SetDlgItemTextW, SetForegroundWindow, + SetTimer, SetWindowLongPtrW, SetWindowPos, SetWindowTextW, ShowWindow, TPM_RETURNCMD, + TPM_RIGHTALIGN, TrackPopupMenuEx, TranslateAcceleratorW, TranslateMessage, WINDOW_EX_STYLE, + WINDOW_LONG_PTR_INDEX, WINDOW_STYLE, WM_APP, WM_CLOSE, WM_COMMAND, WM_COPYDATA, WM_CREATE, + WM_CTLCOLORBTN, WM_CTLCOLOREDIT, WM_CTLCOLORSTATIC, WM_DESTROY, WM_DPICHANGED, WM_DRAWITEM, + WM_DROPFILES, WM_ERASEBKGND, WM_GETFONT, WM_GETMINMAXINFO, WM_INITDIALOG, WM_KEYDOWN, + WM_MOUSEWHEEL, WM_NCCREATE, WM_NCDESTROY, WM_NOTIFY, WM_PRINTCLIENT, WM_SETFONT, WM_SETREDRAW, + WM_SETTINGCHANGE, WM_SIZE, WM_SYSCOLORCHANGE, WM_THEMECHANGED, WM_TIMER, WNDCLASSEXW, WS_CHILD, + WS_CLIPCHILDREN, WS_CLIPSIBLINGS, WS_EX_ACCEPTFILES, WS_EX_APPWINDOW, WS_EX_TOPMOST, + WS_OVERLAPPEDWINDOW, WS_POPUP, WS_TABSTOP, }; use windows::core::{PCWSTR, PWSTR, w}; @@ -262,6 +262,8 @@ struct WindowState { tab_hover: TabHover, /// Ctrl+wheel movement not yet a whole notch. zoom_wheel: i32, + /// The active tab's column widths across zoom changes. + column_zoom: crate::zoom::ColumnZoom, /// The toolbar or panel button under the mouse, painted hovered. hover_button: HWND, /// The search field's frame, painted on the top bar. @@ -348,6 +350,7 @@ struct BackgroundTab { sheets: Option>, columns: NativeColumns, column_widths: Vec, + column_zoom: crate::zoom::ColumnZoom, rows: SlidingRowWindow, cache: Arc, top_row: Option, @@ -580,6 +583,7 @@ pub(crate) fn run(options: ShellOptions) -> Result { tab_labels: Vec::new(), tab_hover: TabHover::default(), zoom_wheel: 0, + column_zoom: crate::zoom::ColumnZoom::default(), hover_button: HWND::default(), search_frame: None, settings: settings::SettingsHandles::default(), @@ -1294,20 +1298,15 @@ fn verify_zoom_repaints_only_rows(window: HWND) -> Result<(), ShellError> { .ok_or_else(|| ShellError::new("zoom paint smoke state is unavailable"))?; // SAFETY: Painting what is pending clears every update region first. unsafe { - let _ = RedrawWindow( - Some(window), - None, - None, - RDW_UPDATENOW | windows::Win32::Graphics::Gdi::RDW_ALLCHILDREN, - ); + let _ = RedrawWindow(Some(window), None, None, RDW_UPDATENOW | RDW_ALLCHILDREN); } let result = set_grid_zoom(window, other_zoom_level(original)); // SAFETY: Both are live children; a null rectangle only asks whether // part of the window waits to be painted. let (rows_pending, bar_pending) = unsafe { ( - windows::Win32::Graphics::Gdi::GetUpdateRect(list, None, false).as_bool(), - windows::Win32::Graphics::Gdi::GetUpdateRect(topbar, None, false).as_bool(), + GetUpdateRect(list, None, false).as_bool(), + GetUpdateRect(topbar, None, false).as_bool(), ) }; let restore = set_grid_zoom(window, original); @@ -1386,12 +1385,7 @@ fn verify_zoom_with_rows(window: HWND) -> Result<(), ShellError> { .ok_or_else(|| ShellError::new("zoom row smoke state is unavailable"))?; // SAFETY: Painting what is pending clears every update region first. unsafe { - let _ = RedrawWindow( - Some(window), - None, - None, - RDW_UPDATENOW | windows::Win32::Graphics::Gdi::RDW_ALLCHILDREN, - ); + let _ = RedrawWindow(Some(window), None, None, RDW_UPDATENOW | RDW_ALLCHILDREN); } let before = cells(); let result = set_grid_zoom(window, other_zoom_level(original)); @@ -2265,44 +2259,86 @@ fn zoom_grid(window: HWND, direction: i32) { } } -/// Rebuilds the fonts at `zoom` percent, scales the column widths by the same -/// ratio in every tab, and saves the level. +/// Sets the rows' zoom: new grid fonts, column widths scaled from their +/// bases in every tab, and the saved level. +/// +/// Only the rows change, so nothing else is touched. The list's redraw is +/// off while it takes the fonts and widths, because each column width set +/// with redraw on repaints every visible row before it returns. The step +/// paints nothing itself: it leaves the list invalid, and Windows paints it +/// once the message queue is empty, so a burst of steps paints once. fn set_grid_zoom(window: HWND, zoom: u32) -> Result<(), ShellError> { - // SAFETY: The live HWND provides its current monitor DPI. - let dpi = unsafe { GetDpiForWindow(window) }.max(96); let pointer = state_pointer_for(window); // SAFETY: Zoom changes run on the window's UI thread. let state = unsafe { pointer.as_mut() } .ok_or_else(|| ShellError::new("window state is unavailable"))?; let previous_zoom = state.theme.zoom; + let zoom = crate::zoom::normalize_zoom(zoom); if zoom == previous_zoom { return Ok(()); } - let next = ThemeResources::new(state.theme.preference, dpi, zoom)?; - let zoom = next.zoom; - let previous = std::mem::replace(&mut state.theme, next); - let result = apply_native_presentation(state); - drop(previous); - result?; - let rescale = |width: i32| { - i32::try_from(i64::from(width) * i64::from(zoom) / i64::from(previous_zoom.max(1))) - .unwrap_or(width) - .max(1) - }; - let widths: Vec = column_widths(state.list, state.columns) - .into_iter() - .map(rescale) - .collect(); - set_column_widths(state.list, &widths); + let fonts = theme::GridFonts::new(state.theme.metrics.dpi, zoom)?; + let previous_fonts = state.theme.fonts.replace_grid(fonts); + state.theme.zoom = zoom; + let list = state.list; + let current = column_widths(list, state.columns); + let widths = state.column_zoom.rescale(¤t, previous_zoom, zoom); + with_list_redraw_off(list, || { + set_font_quietly(list, state.theme.fonts.grid()); + if let Some(header) = list_header(list) { + set_font_quietly(header, state.theme.fonts.grid_header()); + } + set_column_widths(list, &widths); + refit_list_header(list); + }); + // The list and its header no longer use the previous fonts. + drop(previous_fonts); for tab in &mut state.background_tabs { - for width in &mut tab.column_widths { - *width = rescale(*width); - } + tab.column_widths = tab + .column_zoom + .rescale(&tab.column_widths, previous_zoom, zoom); + } + // SAFETY: The list is live; this only marks it and its header for the + // next paint, and lets the frame redraw its scroll bars. + unsafe { + let _ = RedrawWindow( + Some(list), + None, + None, + RDW_INVALIDATE | RDW_ERASE | RDW_FRAME | RDW_ALLCHILDREN, + ); + } + if !state.settings.zoom_value.0.is_null() { + set_control_text(state.settings.zoom_value, &format!("{zoom}%"))?; } - refit_list_header(state.list); let _ = theme::save_zoom_percent(zoom); - layout_children(window); - redraw_shell_children(window) + Ok(()) +} + +/// Runs `change` with the list's redraw off, then turns it back on. Turning +/// it on only marks the list for repainting. +fn with_list_redraw_off(list: HWND, change: impl FnOnce()) { + // SAFETY: WM_SETREDRAW carries a flag and no pointers. + unsafe { SendMessageW(list, WM_SETREDRAW, Some(WPARAM(0)), Some(LPARAM(0))) }; + change(); + // SAFETY: As above. + unsafe { SendMessageW(list, WM_SETREDRAW, Some(WPARAM(1)), Some(LPARAM(0))) }; +} + +/// Gives a control a font without asking it to repaint. +fn set_font_quietly(handle: HWND, font: windows::Win32::Graphics::Gdi::HFONT) { + if handle.0.is_null() { + return; + } + // SAFETY: ThemeResources owns the font longer than every child control. + unsafe { + SendMessageW( + handle, + WM_SETFONT, + Some(WPARAM(font.0.addr())), + Some(LPARAM(0)), + ); + } } /// The list sizes its header from the header font only when it lays itself @@ -3273,38 +3309,72 @@ fn message_loop(window: HWND, accelerators: HACCEL) -> Result<(), ShellError> { /// Ctrl+wheel over the rows zooms them one level per notch, as in /// `LeanMark`. Precision touchpads send smaller steps, which add up. fn handle_zoom_wheel(window: HWND, message: &MSG) -> bool { - if message.message != WM_MOUSEWHEEL { - return false; - } - let keys = message.wParam.0 & 0xffff; - if keys & MK_CONTROL.0 as usize == 0 { - return false; - } let pointer = state_pointer_for(window); // SAFETY: Message dispatch and state access are serialized on this thread. let Some(state) = (unsafe { pointer.as_mut() }) else { return false; }; - if message.hwnd != state.list && Some(message.hwnd) != list_header(state.list) { + if !is_zoom_wheel(state, message) { return false; } - let delta = i32::from(i16::from_le_bytes( - u16::try_from((message.wParam.0 >> 16) & 0xffff) - .unwrap_or_default() - .to_le_bytes(), - )); - state.zoom_wheel = state.zoom_wheel.saturating_add(delta); - let notches = state.zoom_wheel / 120; + let mut notches = crate::zoom::wheel_notches(&mut state.zoom_wheel, wheel_delta(message)); + let mut target = crate::zoom::zoom_after_notches(state.theme.zoom, notches); + // Notches already queued behind this one join it, so a fast spin of + // the wheel lays the rows out once, at the level it ends on. + let mut queued = MSG::default(); + // SAFETY: `queued` is writable; peeking leaves the message in place. + while unsafe { + PeekMessageW( + &raw mut queued, + None, + WM_MOUSEWHEEL, + WM_MOUSEWHEEL, + PM_NOREMOVE, + ) + } + .as_bool() + && is_zoom_wheel(state, &queued) + { + // SAFETY: As above; this removes the message just peeked, the first + // wheel message in the queue. + let _ = unsafe { + PeekMessageW( + &raw mut queued, + None, + WM_MOUSEWHEEL, + WM_MOUSEWHEEL, + PM_REMOVE, + ) + }; + let more = crate::zoom::wheel_notches(&mut state.zoom_wheel, wheel_delta(&queued)); + target = crate::zoom::zoom_after_notches(target, more); + notches = notches.saturating_add(more.abs()); + } if notches == 0 { return true; } - state.zoom_wheel -= notches * 120; - for _ in 0..notches.unsigned_abs() { - zoom_grid(window, notches.signum()); + match set_grid_zoom(window, target) { + Ok(()) => set_status(window, &format!("Zoom {target}%")), + Err(error) => set_status(window, &format!("Zoom failed: {error}")), } true } +/// Ctrl+wheel over the rows or their column titles. +fn is_zoom_wheel(state: &WindowState, message: &MSG) -> bool { + message.message == WM_MOUSEWHEEL + && message.wParam.0 & 0xffff & MK_CONTROL.0 as usize != 0 + && (message.hwnd == state.list || Some(message.hwnd) == list_header(state.list)) +} + +fn wheel_delta(message: &MSG) -> i32 { + i32::from(i16::from_le_bytes( + u16::try_from((message.wParam.0 >> 16) & 0xffff) + .unwrap_or_default() + .to_le_bytes(), + )) +} + fn handle_shell_keyboard(window: HWND, message: &MSG) -> bool { if message.message != WM_KEYDOWN { return false; @@ -4791,8 +4861,16 @@ fn create_children(window: HWND) -> Result<(), ()> { // background thread. let count = state.rows.visible_rows(); - insert_column(list, 0, w!("Row"), 112)?; - insert_existing_data_columns(list, state.columns)?; + let row_width = state + .column_zoom + .insert(0, ROW_COLUMN_WIDTH, state.theme.zoom); + insert_column(list, 0, w!("Row"), row_width)?; + insert_existing_data_columns( + list, + state.columns, + state.theme.zoom, + &mut state.column_zoom, + )?; let style = LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER; let style_parameter = isize::try_from(style).unwrap_or_default(); // SAFETY: These control messages are synchronous and use bounded values or @@ -5022,31 +5100,58 @@ fn insert_column( (result.0 != -1).then_some(()).ok_or(()) } -fn insert_existing_data_columns(list: HWND, columns: NativeColumns) -> Result<(), ()> { +fn insert_existing_data_columns( + list: HWND, + columns: NativeColumns, + zoom: u32, + column_zoom: &mut crate::zoom::ColumnZoom, +) -> Result<(), ()> { let Some(kind) = columns.kind else { return (columns.data_columns == 0).then_some(()).ok_or(()); }; for data_index in 1..=columns.data_columns { - insert_data_column(list, kind, data_index)?; + insert_data_column(list, kind, data_index, zoom, column_zoom)?; } Ok(()) } -fn insert_data_column(list: HWND, kind: UiColumnKind, data_index: u8) -> Result<(), ()> { +/// The Row column's width at 100%. +const ROW_COLUMN_WIDTH: i32 = 112; + +/// A data column's width at 100%. +const fn data_column_width(kind: UiColumnKind) -> i32 { + match kind { + UiColumnKind::Preview => 720, + UiColumnKind::Fields => 220, + UiColumnKind::Cells => 160, + } +} + +/// Inserts a data column as wide as its kind's width at `zoom`. +fn insert_data_column( + list: HWND, + kind: UiColumnKind, + data_index: u8, + zoom: u32, + column_zoom: &mut crate::zoom::ColumnZoom, +) -> Result<(), ()> { let index = usize::from(data_index); + if matches!(kind, UiColumnKind::Preview) && data_index != 1 { + return Err(()); + } + let width = column_zoom.insert(index, data_column_width(kind), zoom); match kind { - UiColumnKind::Preview if data_index == 1 => insert_column(list, index, w!("Preview"), 720), - UiColumnKind::Preview => Err(()), + UiColumnKind::Preview => insert_column(list, index, w!("Preview"), width), UiColumnKind::Fields => { let title = format!("Column {data_index}"); let mut encoded: Vec = title.encode_utf16().collect(); encoded.push(0); - insert_column(list, index, PCWSTR(encoded.as_ptr()), 220) + insert_column(list, index, PCWSTR(encoded.as_ptr()), width) } UiColumnKind::Cells => { let mut encoded: Vec = sheet_column_letters(data_index).encode_utf16().collect(); encoded.push(0); - insert_column(list, index, PCWSTR(encoded.as_ptr()), 160) + insert_column(list, index, PCWSTR(encoded.as_ptr()), width) } } } @@ -5103,8 +5208,14 @@ fn synchronize_data_columns( } while state.columns.data_columns < layout.data_columns() { let next = state.columns.data_columns.saturating_add(1); - insert_data_column(state.list, layout.kind(), next) - .map_err(|()| ShellError::new("native data-column creation failed"))?; + insert_data_column( + state.list, + layout.kind(), + next, + state.theme.zoom, + &mut state.column_zoom, + ) + .map_err(|()| ShellError::new("native data-column creation failed"))?; state.columns.data_columns = next; } Ok(()) @@ -5694,6 +5805,7 @@ fn stash_active(state: &mut WindowState) -> Result { sheets: state.sheets.take(), columns: state.columns, column_widths, + column_zoom: state.column_zoom.clone(), rows, cache, top_row, @@ -5724,11 +5836,18 @@ fn restore_tab( state.pending_reveal = None; reset_data_columns(state)?; - insert_existing_data_columns(state.list, tab.columns) - .map_err(|()| ShellError::new("native data-column restore failed"))?; + insert_existing_data_columns( + state.list, + tab.columns, + state.theme.zoom, + &mut state.column_zoom, + ) + .map_err(|()| ShellError::new("native data-column restore failed"))?; state.columns = tab.columns; sorting::update_header(state); - set_column_widths(state.list, &tab.column_widths); + with_list_redraw_off(state.list, || { + set_column_widths(state.list, &tab.column_widths); + }); state.replace_cache(tab.rows, tab.cache)?; set_native_item_count(state.list, state.rows.visible_rows()); apply_chrome(state, &tab.chrome)?; diff --git a/crates/leanrows-win32/src/native/theme.rs b/crates/leanrows-win32/src/native/theme.rs index 1e88a28..b2223db 100644 --- a/crates/leanrows-win32/src/native/theme.rs +++ b/crates/leanrows-win32/src/native/theme.rs @@ -384,23 +384,28 @@ impl ThemeFonts { title_definition.lfHeight = -scaled(16, normalized_dpi(dpi)); let mut legend_definition = semibold_definition; legend_definition.lfHeight = caption_height(body_definition.lfHeight); - let mut grid_definition = body_definition; - grid_definition.lfHeight = crate::zoom::scale_by_zoom(body_definition.lfHeight, zoom); - let mut grid_header_definition = semibold_definition; - grid_header_definition.lfHeight = - crate::zoom::scale_by_zoom(semibold_definition.lfHeight, zoom); + let grid = GridFonts::from_body(body_definition, zoom)?; Ok(Self { body: OwnedFont::new(&body_definition)?, semibold: OwnedFont::new(&semibold_definition)?, caption: OwnedFont::new(&caption_definition)?, heading: OwnedFont::new(&heading_definition)?, - grid: OwnedFont::new(&grid_definition)?, - grid_header: OwnedFont::new(&grid_header_definition)?, + grid: grid.grid, + grid_header: grid.grid_header, title: OwnedFont::new(&title_definition)?, legend: OwnedFont::new(&legend_definition)?, }) } + /// Puts zoomed grid fonts in place and returns the previous ones, which + /// must outlive every control still using them. + pub(super) fn replace_grid(&mut self, next: GridFonts) -> GridFonts { + GridFonts { + grid: std::mem::replace(&mut self.grid, next.grid), + grid_header: std::mem::replace(&mut self.grid_header, next.grid_header), + } + } + /// The settings panel's heading. #[must_use] pub(super) const fn title(&self) -> HFONT { @@ -446,6 +451,41 @@ impl ThemeFonts { } } +/// The two fonts a zoom step changes: the rows and their column titles. +/// Every other font keeps its size, so a zoom step creates only these. +#[derive(Debug)] +pub(super) struct GridFonts { + grid: OwnedFont, + grid_header: OwnedFont, +} + +impl GridFonts { + /// The grid fonts at `zoom` percent for a window at `dpi`, the same + /// fonts a theme built at that zoom has. + pub(super) fn new(dpi: u32, zoom: u32) -> Result { + Self::from_body(message_logfont(dpi), crate::zoom::normalize_zoom(zoom)) + } + + fn from_body(body: LOGFONTW, zoom: u32) -> Result { + let (grid, grid_header) = grid_definitions(body, zoom); + Ok(Self { + grid: OwnedFont::new(&grid)?, + grid_header: OwnedFont::new(&grid_header)?, + }) + } +} + +/// The rows' font and the column titles' semibold font at `zoom` percent of +/// the body font. +fn grid_definitions(body: LOGFONTW, zoom: u32) -> (LOGFONTW, LOGFONTW) { + let mut grid = body; + grid.lfHeight = crate::zoom::scale_by_zoom(body.lfHeight, zoom); + let mut grid_header = body; + grid_header.lfWeight = i32::try_from(FW_SEMIBOLD.0).unwrap_or(600); + grid_header.lfHeight = crate::zoom::scale_by_zoom(body.lfHeight, zoom); + (grid, grid_header) +} + #[derive(Debug)] pub(super) struct ThemeBrushes { canvas: OwnedBrush, @@ -989,10 +1029,26 @@ impl Drop for OwnedRegistryKey { mod tests { use super::{ EffectiveTheme, Palette, SWATCH_RING, Swatch, ThemeMode, UiColor, UiMetrics, - parse_apps_use_light_theme, parse_theme_mode, select_effective_theme, - theme_accessible_name, theme_display_name, theme_tooltip, wordmark_colors, + fallback_logfont, grid_definitions, parse_apps_use_light_theme, parse_theme_mode, + select_effective_theme, theme_accessible_name, theme_display_name, theme_tooltip, + wordmark_colors, }; + #[test] + fn grid_fonts_scale_the_body_font_and_keep_its_face() { + let body = fallback_logfont(96); + assert_eq!(body.lfHeight, -12); + for (zoom, height) in [(50, -6), (100, -12), (125, -15), (300, -36)] { + let (grid, header) = grid_definitions(body, zoom); + assert_eq!(grid.lfHeight, height); + assert_eq!(header.lfHeight, height); + assert_eq!(grid.lfWeight, body.lfWeight); + assert_eq!(header.lfWeight, 600); + assert_eq!(grid.lfFaceName, body.lfFaceName); + assert_eq!(header.lfFaceName, body.lfFaceName); + } + } + #[test] fn theme_cycle_and_labels_are_stable() { // LeanMark's order: System, Light, Dark, Dim, Dusk, then System. diff --git a/crates/leanrows-win32/src/zoom.rs b/crates/leanrows-win32/src/zoom.rs index dac40c8..fcfa7bc 100644 --- a/crates/leanrows-win32/src/zoom.rs +++ b/crates/leanrows-win32/src/zoom.rs @@ -56,9 +56,132 @@ pub(crate) fn scale_by_zoom(size: i32, zoom: u32) -> i32 { i32::try_from(scaled).unwrap_or(if size < 0 { i32::MIN } else { i32::MAX }) } +/// The level after `notches` steps from `current`: positive notches zoom in, +/// negative ones zoom out, and the ends hold. A burst of Ctrl+wheel notches +/// becomes one change to this level instead of one change per notch. +#[must_use] +pub(crate) fn zoom_after_notches(current: u32, notches: i32) -> u32 { + (0..notches.unsigned_abs()).fold(current, |level, _| step_zoom(level, notches.signum())) +} + +/// Adds a wheel `delta` to the movement not yet a whole notch and returns the +/// whole notches, 120 units each. Precision touchpads send smaller steps, +/// which add up. +pub(crate) fn wheel_notches(pending: &mut i32, delta: i32) -> i32 { + *pending = pending.saturating_add(delta); + let notches = *pending / 120; + *pending -= notches * 120; + notches +} + +/// Column widths across zoom changes. +/// +/// Each column keeps a basis, a width at a known zoom. A zoom change scales +/// the basis, not the width shown before it, so stepping out and back lands +/// on the same widths, and a column at a level is as wide as a column opened +/// at that level. A width that differs from the one last applied was set by +/// the user, and becomes that column's new basis. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ColumnZoom { + columns: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ColumnBasis { + width: i32, + zoom: u32, + /// The width last given to the list; -1 when none was. + applied: i32, +} + +impl ColumnBasis { + const UNKNOWN: Self = Self { + width: 0, + zoom: DEFAULT_ZOOM, + applied: -1, + }; +} + +impl ColumnZoom { + /// Records column `index`, `design` pixels wide at 100%, shown at `zoom`, + /// and returns its width at that zoom. + pub(crate) fn insert(&mut self, index: usize, design: i32, zoom: u32) -> i32 { + let applied = scale_by_zoom(design, zoom).max(1); + if self.columns.len() <= index { + self.columns.resize(index + 1, ColumnBasis::UNKNOWN); + } + self.columns[index] = ColumnBasis { + width: design, + zoom: DEFAULT_ZOOM, + applied, + }; + applied + } + + /// The widths at zoom `to`, given the widths `current` shown at `from`. + pub(crate) fn rescale(&mut self, current: &[i32], from: u32, to: u32) -> Vec { + self.columns.resize(current.len(), ColumnBasis::UNKNOWN); + current + .iter() + .zip(self.columns.iter_mut()) + .map(|(&width, basis)| { + if basis.applied != width { + *basis = ColumnBasis { + width, + zoom: from.max(1), + applied: width, + }; + } + basis.applied = scale_width(basis.width, basis.zoom, to); + basis.applied + }) + .collect() + } +} + +/// `width` at zoom `from`, scaled to zoom `to`, rounded to the nearest pixel +/// and at least one pixel. +fn scale_width(width: i32, from: u32, to: u32) -> i32 { + let from = i64::from(from.max(1)); + let scaled = (i64::from(width) * i64::from(to) + from / 2) / from; + i32::try_from(scaled).unwrap_or(i32::MAX).max(1) +} + #[cfg(test)] mod tests { - use super::{DEFAULT_ZOOM, ZOOM_LEVELS, normalize_zoom, scale_by_zoom, step_zoom}; + use super::{ + ColumnZoom, DEFAULT_ZOOM, ZOOM_LEVELS, normalize_zoom, scale_by_zoom, step_zoom, + wheel_notches, zoom_after_notches, + }; + + #[test] + fn wheel_movement_adds_up_to_whole_notches() { + let mut pending = 0; + assert_eq!(wheel_notches(&mut pending, 120), 1); + assert_eq!(pending, 0); + assert_eq!(wheel_notches(&mut pending, 40), 0); + assert_eq!(wheel_notches(&mut pending, 40), 0); + assert_eq!(wheel_notches(&mut pending, 40), 1); + assert_eq!(pending, 0); + assert_eq!(wheel_notches(&mut pending, -360), -3); + assert_eq!(wheel_notches(&mut pending, 250), 2); + assert_eq!(pending, 10); + assert_eq!(wheel_notches(&mut pending, -20), 0); + assert_eq!(pending, -10); + } + + #[test] + fn queued_notches_in_both_directions_end_where_single_steps_would() { + // Up eight from 250% holds at 300%, then down one: 250%, as eight + // single steps and one more would land. + let target = zoom_after_notches(zoom_after_notches(250, 8), -1); + let mut level = 250; + for direction in [1, 1, 1, 1, 1, 1, 1, 1, -1] { + level = step_zoom(level, direction); + } + assert_eq!(target, level); + assert_eq!(target, 250); + } #[test] fn levels_are_leanmarks() { @@ -111,4 +234,127 @@ mod tests { assert_eq!(scale_by_zoom(0, 200), 0); assert_eq!(scale_by_zoom(i32::MAX, 300), i32::MAX); } + + #[test] + fn a_burst_of_notches_is_one_change_to_the_final_level() { + // Eight notches up from 100% is one change to 300%, not eight steps. + assert_eq!(zoom_after_notches(100, 8), 300); + assert_eq!(zoom_after_notches(300, -12), 50); + assert_eq!(zoom_after_notches(125, 1), 150); + assert_eq!(zoom_after_notches(125, -2), 100); + assert_eq!(zoom_after_notches(110, 0), 110); + // The result equals the single steps it replaces. + let mut level = 67; + for _ in 0..5 { + level = step_zoom(level, 1); + } + assert_eq!(zoom_after_notches(67, 5), level); + assert_eq!(zoom_after_notches(50, -40), 50); + } + + /// Every level a zoom passes through, 100% to 300%, down to 50% and back. + fn round_trip() -> Vec { + let up = ZOOM_LEVELS.iter().copied().filter(|level| *level > 100); + let down = ZOOM_LEVELS.iter().rev().copied().skip(1); + let back = ZOOM_LEVELS + .iter() + .copied() + .filter(|level| *level > 50 && *level <= 100); + up.chain(down).chain(back).collect() + } + + fn zoomed(columns: &mut ColumnZoom, widths: &[i32], path: &[u32]) -> Vec { + let mut widths = widths.to_vec(); + let mut from = DEFAULT_ZOOM; + for &to in path { + widths = columns.rescale(&widths, from, to); + from = to; + } + widths + } + + #[test] + fn zooming_out_and_back_returns_the_same_widths() { + // The Row, Preview, field and sheet defaults. + let defaults = [112, 720, 220, 160]; + let mut columns = ColumnZoom::default(); + let widths: Vec = defaults + .iter() + .enumerate() + .map(|(index, design)| columns.insert(index, *design, DEFAULT_ZOOM)) + .collect(); + assert_eq!(widths, defaults); + let path = round_trip(); + assert_eq!(path.first(), Some(&110)); + assert_eq!(path.last(), Some(&100)); + for _ in 0..3 { + assert_eq!(zoomed(&mut columns, &widths, &path), defaults); + } + } + + #[test] + fn a_zoomed_column_is_as_wide_as_one_opened_at_that_level() { + for design in [112, 720, 220, 160, 1, 7] { + let mut stepped = ColumnZoom::default(); + let mut width = stepped.insert(0, design, DEFAULT_ZOOM); + let mut from = DEFAULT_ZOOM; + for to in round_trip() { + width = stepped.rescale(&[width], from, to)[0]; + from = to; + let mut cold = ColumnZoom::default(); + assert_eq!(width, cold.insert(0, design, to), "{design} px at {to}%"); + } + } + // Columns opened at 300% and zoomed to 50% match ones opened at 50%. + let mut columns = ColumnZoom::default(); + let at_300 = [columns.insert(0, 112, 300), columns.insert(1, 220, 300)]; + assert_eq!(at_300, [336, 660]); + assert_eq!(columns.rescale(&at_300, 300, 50), [56, 110]); + assert_eq!([scale_by_zoom(112, 50), scale_by_zoom(220, 50)], [56, 110]); + } + + #[test] + fn a_width_the_user_set_becomes_the_new_basis() { + let mut columns = ColumnZoom::default(); + let widths = [columns.insert(0, 112, 100), columns.insert(1, 220, 100)]; + let at_150 = columns.rescale(&widths, 100, 150); + assert_eq!(at_150, [168, 330]); + // The user drags the second column to 301 px at 150%. + let dragged = [at_150[0], 301]; + let at_200 = columns.rescale(&dragged, 150, 200); + assert_eq!(at_200, [224, 401]); + let back = columns.rescale(&at_200, 200, 150); + assert_eq!(back, [168, 301]); + // A column the list gained since the last step starts from its width. + let grown = columns.rescale(&[168, 301, 90], 150, 300); + assert_eq!(grown, [336, 602, 180]); + } + + #[test] + fn a_tab_shown_again_carries_on_from_its_widths() { + // A tab comes back at 125%: its columns are inserted at that level, + // then given the widths it had, one of them set by the user. + let mut shown = ColumnZoom::default(); + assert_eq!(shown.insert(0, 112, 125), 140); + assert_eq!(shown.insert(1, 220, 125), 275); + assert_eq!(shown.insert(2, 220, 125), 275); + let widths = [140, 301, 275]; + assert_eq!(shown.rescale(&widths, 125, 100), [112, 241, 220]); + assert_eq!(shown.rescale(&[112, 241, 220], 100, 125), [140, 301, 275]); + // A new document reuses the slots; inserting replaces each basis. + assert_eq!(shown.insert(1, 720, 125), 900); + assert_eq!(shown.rescale(&[140, 900], 125, 50), [56, 360]); + } + + #[test] + fn an_index_past_the_end_leaves_no_borrowed_basis() { + // Inserting column 2 first must not give columns 0 and 1 its basis. + let mut columns = ColumnZoom::default(); + assert_eq!(columns.insert(2, 220, 100), 220); + assert_eq!(columns.rescale(&[220, 220, 220], 100, 200), [440, 440, 440]); + let mut columns = ColumnZoom::default(); + columns.insert(2, 220, 100); + // Column 0 is 112 px; it starts from its own width, not 220. + assert_eq!(columns.rescale(&[112, 50, 220], 100, 50), [56, 25, 110]); + } } From bcc478d0b8650e58ea786ba102cf6608966b4ba8 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:31:15 -0600 Subject: [PATCH 3/6] Cover queued Ctrl+wheel notches in the shell smoke 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. --- crates/leanrows-win32/src/native.rs | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 370d8aa..85972a5 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1167,6 +1167,7 @@ fn verify_grid_zoom(window: HWND) -> Result<(), ShellError> { verify_zoom_column_widths(window)?; verify_zoom_repaints_only_rows(window)?; verify_zoom_background_tab(window)?; + verify_zoom_wheel_burst(window)?; for (command, expected) in [ (ID_VIEW_ZOOM_IN, 110), (ID_VIEW_ZOOM_IN, 125), @@ -1369,6 +1370,60 @@ fn verify_zoom_background_tab(window: HWND) -> Result<(), ShellError> { Ok(()) } +/// Ctrl+wheel notches queued behind the first one join it: three queued +/// notches from 100% are one change to 125%, and none is left queued. +fn verify_zoom_wheel_burst(window: HWND) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + // SAFETY: Smoke runs synchronously on the window's owning UI thread. + let (list, original) = unsafe { pointer.as_ref() } + .map(|state| (state.list, state.theme.zoom)) + .ok_or_else(|| ShellError::new("zoom wheel smoke state is unavailable"))?; + let notch = WPARAM((120_usize << 16) | MK_CONTROL.0 as usize); + for _ in 0..3 { + // SAFETY: A pointer-free wheel message for this thread's live list. + unsafe { PostMessageW(Some(list), WM_MOUSEWHEEL, notch, LPARAM(0)) } + .map_err(|error| ShellError::new(format!("zoom wheel smoke post failed: {error}")))?; + } + let mut message = MSG::default(); + // SAFETY: `message` is writable; this takes the first queued notch. + let first = unsafe { + PeekMessageW( + &raw mut message, + Some(list), + WM_MOUSEWHEEL, + WM_MOUSEWHEEL, + PM_REMOVE, + ) + } + .as_bool(); + let handled = first && handle_zoom_wheel(window, &message); + let mut left = 0; + // SAFETY: As above; this empties the queue of any notch left over. + while unsafe { + PeekMessageW( + &raw mut message, + Some(list), + WM_MOUSEWHEEL, + WM_MOUSEWHEEL, + PM_REMOVE, + ) + } + .as_bool() + { + left += 1; + } + // SAFETY: As above. + let zoom = unsafe { pointer.as_ref() }.map_or(0, |state| state.theme.zoom); + set_grid_zoom(window, original)?; + let expected = crate::zoom::zoom_after_notches(original, 3); + if !handled || left != 0 || zoom != expected { + return Err(ShellError::new(format!( + "three queued notches from {original}% gave {zoom}% with {left} left queued, not {expected}%" + ))); + } + Ok(()) +} + /// With rows on screen, a zoom step asks the list for no cell before it /// returns, and the list paints its rows once afterwards. fn verify_zoom_with_rows(window: HWND) -> Result<(), ShellError> { From e5f8e9be876893201e7bc2915ac448f9c4bb725e Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:38:43 -0600 Subject: [PATCH 4/6] Drop the header refit and the extra invalidate from the zoom step 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. --- crates/leanrows-win32/src/native.rs | 113 +++++++++++++++------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 85972a5..04d4f6c 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -28,8 +28,8 @@ use windows::Win32::Graphics::Gdi::{ DrawTextW, Ellipse, ExtCreatePen, FillRect, FillRgn, FrameRgn, GetDC, GetPixel, GetStockObject, GetSysColorBrush, GetTextExtentPoint32W, GetUpdateRect, HBRUSH, HDC, HGDIOBJ, InvalidateRect, LOGBRUSH, LineTo, MoveToEx, NULL_BRUSH, PS_ENDCAP_ROUND, PS_GEOMETRIC, PS_SOLID, - RDW_ALLCHILDREN, RDW_ERASE, RDW_FRAME, RDW_INVALIDATE, RDW_UPDATENOW, RGN_AND, RedrawWindow, - ReleaseDC, SelectObject, SetBkColor, SetBkMode, SetTextColor, TRANSPARENT, UpdateWindow, + RDW_ALLCHILDREN, RDW_ERASE, RDW_INVALIDATE, RDW_UPDATENOW, RGN_AND, RedrawWindow, ReleaseDC, + SelectObject, SetBkColor, SetBkMode, SetTextColor, TRANSPARENT, UpdateWindow, }; use windows::Win32::System::LibraryLoader::{FindResourceW, GetModuleHandleW}; use windows::Win32::System::SystemServices::{ @@ -1243,6 +1243,7 @@ fn verify_zoom_column_widths(window: HWND) -> Result<(), ShellError> { .iter() .filter(|level| **level > 50 && **level <= 100); let mut previous = crate::zoom::DEFAULT_ZOOM; + let mut header_at = [0; 2]; for &level in up.chain(down).chain(back) { let command = if level > previous { ID_VIEW_ZOOM_IN @@ -1269,6 +1270,26 @@ fn verify_zoom_column_widths(window: HWND) -> Result<(), ShellError> { "at {level}% the columns are {shown:?} px, not {cold:?} as when opened there" ))); } + // SAFETY: As above. + let (shown, fitted) = unsafe { pointer.as_ref() } + .and_then(|state| header_heights(state.list)) + .ok_or_else(|| ShellError::new("zoom smoke could not measure the column titles"))?; + if shown != fitted { + return Err(ShellError::new(format!( + "at {level}% the column titles are {shown} px high, not the {fitted} px their font needs" + ))); + } + match level { + 50 => header_at[0] = shown, + 300 => header_at[1] = shown, + _ => {} + } + } + if header_at[1] <= header_at[0] { + return Err(ShellError::new(format!( + "the column titles are {} px high at 300% and {} px at 50%", + header_at[1], header_at[0] + ))); } if widths()? != start { return Err(ShellError::new( @@ -1288,6 +1309,33 @@ fn other_zoom_level(zoom: u32) -> u32 { } } +/// The header's height and the height `HDM_LAYOUT` gives for its font. +fn header_heights(list: HWND) -> Option<(i32, i32)> { + let header = list_header(list)?; + let mut shown = RECT::default(); + let mut bounds = RECT::default(); + // SAFETY: Both handles are live and the RECTs are writable. + unsafe { + GetWindowRect(header, &raw mut shown).ok()?; + GetClientRect(list, &raw mut bounds).ok()?; + } + let mut position = windows::Win32::UI::WindowsAndMessaging::WINDOWPOS::default(); + let mut layout = windows::Win32::UI::Controls::HDLAYOUT { + prc: &raw mut bounds, + pwpos: &raw mut position, + }; + // SAFETY: HDM_LAYOUT fills `position` from `bounds`; both outlive the call. + let laid_out = unsafe { + SendMessageW( + header, + windows::Win32::UI::Controls::HDM_LAYOUT, + None, + Some(LPARAM((&raw mut layout) as isize)), + ) + }; + (laid_out.0 != 0).then_some((shown.bottom - shown.top, position.cy)) +} + /// A zoom step changes only the rows, so it leaves only the list to repaint, /// and it paints nothing before it returns: the paint comes once, after the /// step and any steps queued behind it. @@ -1304,9 +1352,10 @@ fn verify_zoom_repaints_only_rows(window: HWND) -> Result<(), ShellError> { let result = set_grid_zoom(window, other_zoom_level(original)); // SAFETY: Both are live children; a null rectangle only asks whether // part of the window waits to be painted. - let (rows_pending, bar_pending) = unsafe { + let (rows_pending, titles_pending, bar_pending) = unsafe { ( GetUpdateRect(list, None, false).as_bool(), + list_header(list).is_some_and(|header| GetUpdateRect(header, None, false).as_bool()), GetUpdateRect(topbar, None, false).as_bool(), ) }; @@ -1318,6 +1367,11 @@ fn verify_zoom_repaints_only_rows(window: HWND) -> Result<(), ShellError> { "a zoom step painted the rows before it returned", )); } + if !titles_pending { + return Err(ShellError::new( + "a zoom step did not leave the column titles to repaint", + )); + } if bar_pending { return Err(ShellError::new("a zoom step invalidated the toolbar")); } @@ -1371,7 +1425,7 @@ fn verify_zoom_background_tab(window: HWND) -> Result<(), ShellError> { } /// Ctrl+wheel notches queued behind the first one join it: three queued -/// notches from 100% are one change to 125%, and none is left queued. +/// notches from 100% are one change to 150%, and none is left queued. fn verify_zoom_wheel_burst(window: HWND) -> Result<(), ShellError> { let pointer = state_pointer_for(window); // SAFETY: Smoke runs synchronously on the window's owning UI thread. @@ -2344,7 +2398,6 @@ fn set_grid_zoom(window: HWND, zoom: u32) -> Result<(), ShellError> { set_font_quietly(header, state.theme.fonts.grid_header()); } set_column_widths(list, &widths); - refit_list_header(list); }); // The list and its header no longer use the previous fonts. drop(previous_fonts); @@ -2353,16 +2406,6 @@ fn set_grid_zoom(window: HWND, zoom: u32) -> Result<(), ShellError> { .column_zoom .rescale(&tab.column_widths, previous_zoom, zoom); } - // SAFETY: The list is live; this only marks it and its header for the - // next paint, and lets the frame redraw its scroll bars. - unsafe { - let _ = RedrawWindow( - Some(list), - None, - None, - RDW_INVALIDATE | RDW_ERASE | RDW_FRAME | RDW_ALLCHILDREN, - ); - } if !state.settings.zoom_value.0.is_null() { set_control_text(state.settings.zoom_value, &format!("{zoom}%"))?; } @@ -2371,7 +2414,8 @@ fn set_grid_zoom(window: HWND, zoom: u32) -> Result<(), ShellError> { } /// Runs `change` with the list's redraw off, then turns it back on. Turning -/// it on only marks the list for repainting. +/// it on lays the list and its header out again for the new font and marks +/// both for repainting; it paints nothing before it returns. fn with_list_redraw_off(list: HWND, change: impl FnOnce()) { // SAFETY: WM_SETREDRAW carries a flag and no pointers. unsafe { SendMessageW(list, WM_SETREDRAW, Some(WPARAM(0)), Some(LPARAM(0))) }; @@ -2396,39 +2440,6 @@ fn set_font_quietly(handle: HWND, font: windows::Win32::Graphics::Gdi::HFONT) { } } -/// The list sizes its header from the header font only when it lays itself -/// out, so a one-pixel resize makes it measure the zoomed font. -fn refit_list_header(list: HWND) { - let mut bounds = RECT::default(); - // SAFETY: The list is a live child; its rectangle is read and restored. - if unsafe { GetClientRect(list, &raw mut bounds) }.is_err() { - return; - } - let width = bounds.right.saturating_sub(bounds.left); - let height = bounds.bottom.saturating_sub(bounds.top); - // SAFETY: Both resizes keep the list's position and z-order. - unsafe { - let _ = SetWindowPos( - list, - None, - 0, - 0, - width, - height.saturating_add(1), - SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, - ); - let _ = SetWindowPos( - list, - None, - 0, - 0, - width, - height, - SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, - ); - } -} - /// Repaints the bar so the search field's frame follows its focus. fn repaint_top_bar(window: HWND) { let pointer = state_pointer_for(window); @@ -5900,9 +5911,7 @@ fn restore_tab( .map_err(|()| ShellError::new("native data-column restore failed"))?; state.columns = tab.columns; sorting::update_header(state); - with_list_redraw_off(state.list, || { - set_column_widths(state.list, &tab.column_widths); - }); + set_column_widths(state.list, &tab.column_widths); state.replace_cache(tab.rows, tab.cache)?; set_native_item_count(state.list, state.rows.visible_rows()); apply_chrome(state, &tab.chrome)?; From 8b663459b3500870c106ec41786ad3183aab057e Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:44:51 -0600 Subject: [PATCH 5/6] Pin the column titles' zoomed font in the zoom smoke 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. --- crates/leanrows-win32/src/native.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 04d4f6c..91e6e08 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1201,6 +1201,14 @@ fn verify_grid_zoom(window: HWND) -> Result<(), ShellError> { ))); } // SAFETY: WM_GETFONT carries no pointers. + let titles = list_header(state.list) + .map(|header| unsafe { SendMessageW(header, WM_GETFONT, None, None) }.0); + if titles != Some(state.theme.fonts.grid_header().0 as isize) { + return Err(ShellError::new(format!( + "the column titles do not have the {expected}% title font" + ))); + } + // SAFETY: WM_GETFONT carries no pointers. let search = unsafe { SendMessageW(state.chrome.search_edit, WM_GETFONT, None, None) }.0; if search != state.theme.fonts.body().0 as isize { From 8391c2c33d9e975906f23e0b2aeff4d5fa5b3eb5 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:39:20 -0600 Subject: [PATCH 6/6] Time zoom steps until the rows have painted 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. --- tools/Measure-LeanRowsZoom.ps1 | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tools/Measure-LeanRowsZoom.ps1 b/tools/Measure-LeanRowsZoom.ps1 index 024330b..a5ab623 100644 --- a/tools/Measure-LeanRowsZoom.ps1 +++ b/tools/Measure-LeanRowsZoom.ps1 @@ -10,8 +10,9 @@ param( # Drives a running LeanRows window from outside and times zoom. The saved # zoom level is set to 100% for the run and put back afterwards. # One step: WM_COMMAND zoom in/out, then wait until the list shows the state a -# settled window has at that level (header height and row height), then flush -# any pending paint with RedrawWindow(RDW_UPDATENOW | RDW_ALLCHILDREN). +# settled window has at that level (header height and row height), then until +# the list and its column titles have painted it: no update region is left and +# a no-op message sent to the list has been answered. # Bursts post the input at once (Ctrl+wheel notches) or every 33 ms (a held # Ctrl+Plus at the keyboard repeat rate) and time until the final level is # shown and painted. A sampler thread records the working set every 2 ms. @@ -62,6 +63,16 @@ public static class ZoomProbe { int viewHeight = (int)((view >> 16) & 0xffff); return ((long)(r.Bottom - r.Top) << 32) | (uint)viewHeight; } + [DllImport("user32.dll")] public static extern bool GetUpdateRect(IntPtr h, IntPtr r, bool erase); + // Waits until the list and its titles have nothing left to paint, then + // sends a no-op message, which the UI thread answers only once it has + // finished the paint it is in. + public static void Painted(IntPtr list) { + IntPtr header = SendMessageW(list, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero); + var clock = Stopwatch.StartNew(); + while ((GetUpdateRect(list, IntPtr.Zero, false) || GetUpdateRect(header, IntPtr.Zero, false)) && clock.ElapsedMilliseconds < 5000) { Thread.Sleep(0); } + SendMessageW(list, 0, IntPtr.Zero, IntPtr.Zero); + } public static void Flush(IntPtr window) { RedrawWindow(window, IntPtr.Zero, IntPtr.Zero, RDW_UPDATENOW | RDW_ALLCHILDREN); } public static void Command(IntPtr window, int id) { SendMessageW(window, WM_COMMAND, (IntPtr)id, IntPtr.Zero); } public static void PostCommand(IntPtr window, int id) { PostMessageW(window, WM_COMMAND, (IntPtr)id, IntPtr.Zero); } @@ -70,10 +81,10 @@ public static class ZoomProbe { long w = ((long)(delta & 0xffff) << 16) | 0x0008; PostMessageW(list, WM_MOUSEWHEEL, (IntPtr)w, IntPtr.Zero); } - // Waits until the list shows `expected`, then flushes paint. Returns ms. + // Waits until the list shows `expected` and has painted it. Returns ms. public static double WaitFor(IntPtr window, IntPtr list, long expected, Stopwatch clock, int timeoutMs) { while (clock.ElapsedMilliseconds < timeoutMs) { - if (State(list) == expected) { Flush(window); return clock.Elapsed.TotalMilliseconds; } + if (State(list) == expected) { Painted(list); Flush(window); return clock.Elapsed.TotalMilliseconds; } Thread.Sleep(0); } return -1;