diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 4b7a331..91e6e08 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_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. @@ -277,6 +279,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, } @@ -345,6 +350,7 @@ struct BackgroundTab { sheets: Option>, columns: NativeColumns, column_widths: Vec, + column_zoom: crate::zoom::ColumnZoom, rows: SlidingRowWindow, cache: Arc, top_row: Option, @@ -577,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(), @@ -586,6 +593,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 +1164,10 @@ 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)?; + verify_zoom_wheel_burst(window)?; for (command, expected) in [ (ID_VIEW_ZOOM_IN, 110), (ID_VIEW_ZOOM_IN, 125), @@ -1189,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 { @@ -1206,6 +1226,306 @@ 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; + let mut header_at = [0; 2]; + 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" + ))); + } + // 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( + "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 + } +} + +/// 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. +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 | 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, 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(), + ) + }; + 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 !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")); + } + 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(()) +} + +/// Ctrl+wheel notches queued behind the first one join it: three 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. + 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> { + 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 | 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. @@ -2056,75 +2376,74 @@ 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); + }); + // 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); + } + 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(()) } -/// 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() { +/// Runs `change` with the list's redraw off, then turns it back on. Turning +/// 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))) }; + 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; } - 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. + // SAFETY: ThemeResources owns the font longer than every child control. 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, + SendMessageW( + handle, + WM_SETFONT, + Some(WPARAM(font.0.addr())), + Some(LPARAM(0)), ); } } @@ -3064,38 +3383,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; @@ -4582,8 +4935,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 @@ -4813,31 +5174,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) } } } @@ -4894,8 +5282,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(()) @@ -5219,6 +5613,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 @@ -5482,6 +5879,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, @@ -5512,8 +5910,13 @@ 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); @@ -6176,6 +6579,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/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]); + } } diff --git a/tools/Measure-LeanRowsZoom.ps1 b/tools/Measure-LeanRowsZoom.ps1 new file mode 100644 index 0000000..a5ab623 --- /dev/null +++ b/tools/Measure-LeanRowsZoom.ps1 @@ -0,0 +1,246 @@ +[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 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. +$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; + } + [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); } + 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` 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) { Painted(list); 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