From 64b9f5ca71559e082de2062fb5e1ce75764cd70b Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:12:02 -0600 Subject: [PATCH 01/10] Add scroll bar checks that fail on the classic bars The shell smoke now gives the rows 5,000 entries and checks that the list keeps a 10 px strip for its vertical bar, not the system bar's width, that a thin bar covers that strip, that a press below its thumb pages down once, that dragging the thumb to the end shows the last page, and that the wheel over it scrolls the rows. It then opens the settings panel and checks that the shortcut list has no horizontal bar, is no wider than its client area, and that every action fits whole in its row beside its keys. On main the first check fails with "the rows keep 17 px for their vertical scroll bar, not a 10 px strip", and the shortcut check, run on its own, with "the shortcut list has a horizontal scroll bar". scroll.rs holds the bar geometry (thumb size and place, the position a dragged thumb stands for, which way a track press pages) with unit tests. Nothing uses it yet. --- crates/leanrows-win32/src/lib.rs | 2 + crates/leanrows-win32/src/native.rs | 263 ++++++++++++++++++++++++++++ crates/leanrows-win32/src/scroll.rs | 219 +++++++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 crates/leanrows-win32/src/scroll.rs diff --git a/crates/leanrows-win32/src/lib.rs b/crates/leanrows-win32/src/lib.rs index f8714dc..93815fb 100644 --- a/crates/leanrows-win32/src/lib.rs +++ b/crates/leanrows-win32/src/lib.rs @@ -15,6 +15,8 @@ mod model; mod native; #[cfg(not(windows))] mod native_stub; +#[allow(dead_code, reason = "the thin scroll bars use it in the next commit")] +mod scroll; #[cfg_attr(not(windows), allow(dead_code))] mod shortcuts; mod sort_coordinator; diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 91e6e08..0505a85 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1017,6 +1017,7 @@ fn verify_shell_smoke( verify_theme_button_cycle(window)?; verify_grid_zoom(window)?; verify_settings_panel(window)?; + verify_thin_scroll_bars(window)?; verify_progress_indicator(state)?; verify_tab_strip(window)?; verify_native_presentation(window, state)?; @@ -1649,6 +1650,268 @@ fn verify_settings_panel(window: HWND) -> Result<(), ShellError> { verification } +/// The thin bars that stand in for a list's system scroll bars: the strip +/// beside or below the rows that one of them covers, or `None`. +fn thin_bar_over(list: HWND, vertical: bool) -> Option<(HWND, RECT)> { + use windows::Win32::UI::WindowsAndMessaging::{FindWindowExW, GetParent}; + // SAFETY: The list is live; its parent's children are only read. + let parent = unsafe { GetParent(list) }.ok()?; + let mut edge = RECT::default(); + // SAFETY: As above; the rectangle is writable. + unsafe { GetWindowRect(list, &raw mut edge) }.ok()?; + let mut child = None; + loop { + // SAFETY: Walks the parent's children of the bar class. + let next = + unsafe { FindWindowExW(Some(parent), child, w!("LeanRowsScrollBar"), None) }.ok()?; + let mut rect = RECT::default(); + // SAFETY: As above. + let _ = unsafe { GetWindowRect(next, &raw mut rect) }; + // SAFETY: As above. + let shown = unsafe { IsWindowVisible(next) }.as_bool(); + let fits = if vertical { + rect.right == edge.right && rect.top == edge.top + } else { + rect.left == edge.left && rect.bottom == edge.bottom + }; + if shown && fits { + return Some((next, rect)); + } + child = Some(next); + } +} + +fn top_row(list: HWND) -> isize { + // SAFETY: LVM_GETTOPINDEX carries no pointers. + unsafe { SendMessageW(list, LVM_GETTOPINDEX, None, None) }.0 +} + +/// Thin, theme-coloured bars stand in for the rows' scroll bars: the list +/// keeps a 10 px strip, not the system bar's width, a bar covers that strip, +/// and its thumb drags, its track pages and the wheel over it scrolls. The +/// settings panel's shortcut list fits its width, needs no horizontal bar +/// and shows every action whole. +#[allow( + clippy::too_many_lines, + reason = "one smoke walks the bars through every way to scroll" +)] +fn verify_thin_scroll_bars(window: HWND) -> Result<(), ShellError> { + use windows::Win32::UI::WindowsAndMessaging::{WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE}; + let pointer = state_pointer_for(window); + // SAFETY: The smoke runs synchronously on the window's UI thread. + let (list, dpi, rows) = unsafe { pointer.as_ref() } + .map(|state| { + ( + state.list, + state.theme.metrics.dpi, + // SAFETY: LVM_GETITEMCOUNT carries no pointers. + unsafe { SendMessageW(state.list, LVM_GETITEMCOUNT, None, None) }.0, + ) + }) + .ok_or_else(|| ShellError::new("scroll bar smoke state is unavailable"))?; + let strip = i32::try_from((10 * u64::from(dpi.max(96)) + 48) / 96).unwrap_or(10); + let set_rows = |count: isize| { + // SAFETY: The owner-data list takes a row count and no pointers. + unsafe { + SendMessageW( + list, + LVM_SETITEMCOUNT, + Some(WPARAM(count.unsigned_abs())), + Some(LPARAM(0)), + ); + let _ = UpdateWindow(list); + } + }; + set_rows(5_000); + let verification = (|| { + let mut frame = RECT::default(); + let mut client = RECT::default(); + // SAFETY: The list is live and both rectangles are writable. + unsafe { + let _ = GetWindowRect(list, &raw mut frame); + let _ = GetClientRect(list, &raw mut client); + } + let kept = (frame.right - frame.left) - (client.right - client.left); + if kept != strip { + return Err(ShellError::new(format!( + "the rows keep {kept} px for their vertical scroll bar, not a {strip} px strip" + ))); + } + let (bar, place) = thin_bar_over(list, true) + .ok_or_else(|| ShellError::new("no thin bar covers the strip beside the rows"))?; + if place.left != frame.right - strip || place.bottom != frame.bottom { + return Err(ShellError::new(format!( + "the thin bar covers x {}..{}, y to {}, not the strip x {}..{}, y to {}", + place.left, + place.right, + place.bottom, + frame.right - strip, + frame.right, + frame.bottom + ))); + } + // SAFETY: LVM_GETCOUNTPERPAGE carries no pointers. + let page = unsafe { SendMessageW(list, LVM_GETCOUNTPERPAGE, None, None) }.0; + let height = place.bottom - place.top; + let at = |y: i32| LPARAM(isize::try_from((y & 0xffff) << 16 | (strip / 2)).unwrap_or(0)); + // SAFETY: Mouse messages carry client coordinates and no pointers. + let mouse = |message: u32, y: i32| unsafe { + SendMessageW(bar, message, Some(WPARAM(0)), Some(at(y))); + }; + // A press on the track below the thumb pages down once. + mouse(WM_LBUTTONDOWN, height - 2); + mouse(WM_LBUTTONUP, height - 2); + if top_row(list) != page { + return Err(ShellError::new(format!( + "a press below the thumb showed row {} first, not row {page}", + top_row(list) + ))); + } + // The thumb at the top, caught and dragged to the end, shows the + // last page. + // SAFETY: LVM_ENSUREVISIBLE carries a row index. + unsafe { SendMessageW(list, LVM_ENSUREVISIBLE, Some(WPARAM(0)), Some(LPARAM(0))) }; + let mut header = RECT::default(); + if let Some(titles) = list_header(list) { + // SAFETY: The header is live and the rectangle writable. + let _ = unsafe { GetWindowRect(titles, &raw mut header) }; + } + let thumb_top = (header.bottom - header.top) + strip / 2; + mouse(WM_LBUTTONDOWN, thumb_top); + mouse(WM_MOUSEMOVE, height + strip * 4); + mouse(WM_LBUTTONUP, height + strip * 4); + let end_row = 5_000 - page; + if top_row(list) != end_row { + return Err(ShellError::new(format!( + "dragging the thumb to the end showed row {} first, not row {end_row}", + top_row(list) + ))); + } + // The wheel over the bar scrolls the rows as it does over them. + // SAFETY: As above. + unsafe { SendMessageW(list, LVM_ENSUREVISIBLE, Some(WPARAM(0)), Some(LPARAM(0))) }; + let down = WPARAM(usize::from(0xff88_u16) << 16); + // SAFETY: The wheel message carries a delta and screen coordinates. + unsafe { SendMessageW(bar, WM_MOUSEWHEEL, Some(down), Some(LPARAM(0))) }; + if top_row(list) <= 0 { + return Err(ShellError::new( + "the wheel over the thin bar did not scroll the rows", + )); + } + Ok(()) + })(); + set_rows(rows); + // SAFETY: As above. + unsafe { SendMessageW(list, LVM_ENSUREVISIBLE, Some(WPARAM(0)), Some(LPARAM(0))) }; + verification?; + verify_shortcut_list_fits(window) +} + +/// The settings panel's shortcut list is no wider than its client area, +/// has no horizontal bar, and every action fits whole in its row beside its +/// keys, on as many lines as it needs. +fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { + use windows::Win32::Graphics::Gdi::{DT_CALCRECT, DT_NOPREFIX, DT_WORDBREAK}; + use windows::Win32::UI::Controls::{LVIR_BOUNDS, LVM_GETITEMRECT}; + use windows::Win32::UI::WindowsAndMessaging::WS_HSCROLL; + // SAFETY: The command is routed synchronously through the window. + unsafe { + SendMessageW( + window, + WM_COMMAND, + Some(WPARAM(usize::from(ID_VIEW_SETTINGS))), + Some(LPARAM(0)), + ); + } + let pointer = state_pointer_for(window); + let verification = (|| { + // SAFETY: As above. + let state = unsafe { pointer.as_ref() } + .ok_or_else(|| ShellError::new("shortcut smoke state is unavailable"))?; + let list = state.settings.shortcuts; + // SAFETY: The list is live; style and rectangles are only read. + let style = unsafe { GetWindowLongPtrW(list, GWL_STYLE) }; + if style & isize::try_from(WS_HSCROLL.0).unwrap_or_default() != 0 { + return Err(ShellError::new( + "the shortcut list has a horizontal scroll bar", + )); + } + let mut client = RECT::default(); + // SAFETY: As above. + let _ = unsafe { GetClientRect(list, &raw mut client) }; + let mut row = RECT { + left: i32::try_from(LVIR_BOUNDS).unwrap_or_default(), + ..Default::default() + }; + // SAFETY: The rectangle outlives the synchronous call. + unsafe { + SendMessageW( + list, + LVM_GETITEMRECT, + Some(WPARAM(1)), + Some(LPARAM((&raw mut row) as isize)), + ); + } + if row.right > client.right { + return Err(ShellError::new(format!( + "the shortcut rows are {} px wide in a {} px list", + row.right - row.left, + client.right + ))); + } + let m = state.theme.metrics; + // SAFETY: The list is live; the DC is released below. + let device = unsafe { GetDC(Some(list)) }; + let mut problem = None; + for entry in &crate::shortcuts::SHORTCUTS { + let keys: Vec = entry.keys.encode_utf16().collect(); + let mut action: Vec = entry.action.encode_utf16().collect(); + let mut size = windows::Win32::Foundation::SIZE::default(); + let mut wrapped = RECT::default(); + let room = (row.right - row.left) - m.space_1 * 2 - m.space_3; + // SAFETY: The theme's fonts outlive the DC; buffers are local. + unsafe { + SelectObject(device, HGDIOBJ(state.theme.fonts.caption().0)); + let _ = GetTextExtentPoint32W(device, &keys, &raw mut size); + SelectObject(device, HGDIOBJ(state.theme.fonts.body().0)); + wrapped.right = room - size.cx; + DrawTextW( + device, + &mut action, + &raw mut wrapped, + DT_CALCRECT | DT_WORDBREAK | DT_NOPREFIX, + ); + } + let fits_across = wrapped.right <= room - size.cx; + let fits_down = wrapped.bottom - wrapped.top + m.space_2 * 2 <= row.bottom - row.top; + if !(fits_across && fits_down) && problem.is_none() { + problem = Some(format!( + "\"{}\" needs {} x {} px beside \"{}\" in a row with {} x {} px", + entry.action, + wrapped.right, + wrapped.bottom - wrapped.top + m.space_2 * 2, + entry.keys, + room - size.cx, + row.bottom - row.top + )); + } + } + // SAFETY: Releases the DC taken above. + unsafe { ReleaseDC(Some(list), device) }; + problem.map_or(Ok(()), |problem| Err(ShellError::new(problem))) + })(); + // SAFETY: As above. + unsafe { + SendMessageW( + window, + WM_COMMAND, + Some(WPARAM(usize::from(settings::ID_SETTINGS_CLOSE))), + Some(LPARAM(0)), + ); + } + verification +} + fn font_height(font: windows::Win32::Graphics::Gdi::HFONT) -> Option { let mut definition = windows::Win32::Graphics::Gdi::LOGFONTW::default(); let size = i32::try_from(size_of::()).ok()?; diff --git a/crates/leanrows-win32/src/scroll.rs b/crates/leanrows-win32/src/scroll.rs new file mode 100644 index 0000000..0099972 --- /dev/null +++ b/crates/leanrows-win32/src/scroll.rs @@ -0,0 +1,219 @@ +//! Geometry for the thin scroll bars, kept apart from Win32 so it can be +//! tested. +//! +//! A bar mirrors the list's own scroll range: `min..=max` in the list's +//! units (rows down, pixels across), `page` units visible, the first one at +//! `pos`. The thumb's length is the visible share of the track, never shorter +//! than a minimum the finger and the mouse can catch. + +/// The list's scroll state for one direction, as `GetScrollInfo` reports it. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ScrollRange { + pub(crate) min: i32, + pub(crate) max: i32, + pub(crate) page: u32, + pub(crate) pos: i32, +} + +impl ScrollRange { + /// Units in the whole range. + fn span(self) -> i64 { + (i64::from(self.max) - i64::from(self.min) + 1).max(0) + } + + /// The farthest `pos` can go: the last page starts here. + fn last_pos(self) -> i64 { + (i64::from(self.max) - i64::from(self.page) + 1).max(i64::from(self.min)) + } + + /// True when there is more than one page to scroll through. + #[must_use] + pub(crate) fn scrolls(self) -> bool { + self.page > 0 && self.span() > i64::from(self.page) + } +} + +/// Where the thumb lies along a track `track` pixels long. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Thumb { + pub(crate) start: i32, + pub(crate) length: i32, +} + +impl Thumb { + #[must_use] + pub(crate) fn end(self) -> i32 { + self.start.saturating_add(self.length) + } +} + +/// The thumb for `range` on a track `track` pixels long, at least +/// `min_length` pixels, or `None` when the list does not scroll. +#[must_use] +pub(crate) fn thumb(range: ScrollRange, track: i32, min_length: i32) -> Option { + if !range.scrolls() || track <= 0 { + return None; + } + let span = range.span(); + let track_i64 = i64::from(track); + let length = (track_i64 * i64::from(range.page) / span) + .max(i64::from(min_length.min(track))) + .min(track_i64); + let travel = track_i64 - length; + let steps = range.last_pos() - i64::from(range.min); + let offset = (i64::from(range.pos) - i64::from(range.min)).clamp(0, steps); + let start = if steps == 0 { + 0 + } else { + (travel * offset + steps / 2) / steps + }; + Some(Thumb { + start: i32::try_from(start).unwrap_or(0), + length: i32::try_from(length).unwrap_or(track), + }) +} + +/// The scroll position that puts the thumb's start at `start` pixels, for a +/// thumb `length` pixels long: the inverse of [`thumb`], used while the +/// thumb is dragged. +#[must_use] +pub(crate) fn position_at(range: ScrollRange, track: i32, length: i32, start: i32) -> i32 { + let travel = i64::from(track) - i64::from(length); + let steps = range.last_pos() - i64::from(range.min); + if travel <= 0 || steps <= 0 { + return range.min; + } + let start = i64::from(start).clamp(0, travel); + let pos = i64::from(range.min) + (steps * start + travel / 2) / travel; + i32::try_from(pos).unwrap_or(range.max) +} + +/// Which way a click on the track pages: towards the start (`-1`) when it is +/// before the thumb, towards the end (`1`) when after, `0` on the thumb. +#[must_use] +pub(crate) fn page_direction(thumb: Thumb, at: i32) -> i32 { + if at < thumb.start { + -1 + } else { + i32::from(at >= thumb.end()) + } +} + +#[cfg(test)] +mod tests { + use super::{ScrollRange, Thumb, page_direction, position_at, thumb}; + + fn rows(count: i32, page: u32, pos: i32) -> ScrollRange { + ScrollRange { + min: 0, + max: count - 1, + page, + pos, + } + } + + #[test] + fn no_thumb_when_everything_fits() { + assert_eq!(thumb(rows(30, 30, 0), 600, 24), None); + assert_eq!(thumb(rows(10, 30, 0), 600, 24), None); + assert_eq!(thumb(rows(0, 0, 0), 600, 24), None); + assert_eq!(thumb(rows(100, 30, 0), 0, 24), None); + } + + #[test] + fn the_thumb_is_the_visible_share_and_moves_with_the_position() { + // 100 rows, 25 visible, on a 400 px track: a 100 px thumb. + assert_eq!( + thumb(rows(100, 25, 0), 400, 24), + Some(Thumb { + start: 0, + length: 100 + }) + ); + // The last page puts the thumb at the end of the track. + assert_eq!( + thumb(rows(100, 25, 75), 400, 24), + Some(Thumb { + start: 300, + length: 100 + }) + ); + assert_eq!( + thumb(rows(100, 25, 37), 400, 24).map(|t| t.start), + Some(148) + ); + // A position past the end is held at the end. + assert_eq!( + thumb(rows(100, 25, 99), 400, 24).map(|t| t.start), + Some(300) + ); + } + + #[test] + fn ten_million_rows_keep_a_thumb_you_can_catch() { + let range = rows(10_000_000, 30, 0); + let top = thumb(range, 800, 24).unwrap_or(Thumb { + start: -1, + length: -1, + }); + assert_eq!( + top, + Thumb { + start: 0, + length: 24 + } + ); + let last = rows(10_000_000, 30, 10_000_000 - 30); + assert_eq!(thumb(last, 800, 24).map(Thumb::end), Some(800)); + let middle = rows(10_000_000, 30, 4_999_985); + assert_eq!(thumb(middle, 800, 24).map(|t| t.start), Some(388)); + } + + #[test] + fn dragging_the_thumb_maps_back_to_the_position() { + let range = rows(100, 25, 0); + assert_eq!(position_at(range, 400, 100, 0), 0); + assert_eq!(position_at(range, 400, 100, 300), 75); + assert_eq!(position_at(range, 400, 100, 400), 75); + assert_eq!(position_at(range, 400, 100, -50), 0); + for pos in 0..=75 { + let at = thumb(rows(100, 25, pos), 400, 24).map_or(-1, |t| t.start); + assert_eq!(position_at(range, 400, 100, at), pos, "row {pos}"); + } + // Across ten million rows every pixel of travel is about 12,800 rows. + let big = rows(10_000_000, 30, 0); + assert_eq!(position_at(big, 800, 24, 776), 9_999_970); + assert_eq!(position_at(big, 800, 24, 1), 12_887); + } + + #[test] + fn pixel_ranges_scroll_across() { + // Columns 2,000 px wide in a 1,200 px list. + let across = ScrollRange { + min: 0, + max: 1_999, + page: 1_200, + pos: 800, + }; + assert_eq!( + thumb(across, 1_200, 24), + Some(Thumb { + start: 480, + length: 720 + }) + ); + assert_eq!(position_at(across, 1_200, 720, 480), 800); + } + + #[test] + fn a_track_click_pages_towards_the_click() { + let thumb = Thumb { + start: 100, + length: 50, + }; + assert_eq!(page_direction(thumb, 99), -1); + assert_eq!(page_direction(thumb, 100), 0); + assert_eq!(page_direction(thumb, 149), 0); + assert_eq!(page_direction(thumb, 150), 1); + } +} From bf41f783a354ba88bd1d8e6ff23773408d3848d9 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:16:47 -0600 Subject: [PATCH 02/10] Draw thin theme-coloured scroll bars over the lists The rows and the settings panel's shortcut list keep their own scroll bars and scroll state, so the wheel, the keyboard, touch panning and UI Automation work as before. A subclass on each list answers WM_NCCALCSIZE with a client area that leaves a 10 px strip for each bar it shows instead of the system bar's width. Windows still draws its bars from the client edge outwards, which is now past the window's edge. Two small sibling windows sit over the strips and draw the bars without arrow buttons: the track in the surface colour, a 6 px rounded thumb in the disabled text colour that grows to 8 px in the secondary text colour under the mouse or while dragged. A thumb drag scrolls the list with LVM_SCROLL, a press on the track pages with the list's own SB_PAGEUP and SB_PAGEDOWN and repeats while held, and the wheel over a bar goes to the list. Ctrl+wheel over the rows' bar zooms as it does over the rows. In high contrast the overlay is off and the system bars come back in system colours. The theme decides this each time it is applied. The shortcut list's rows are as wide as the list less the room a vertical bar takes, so it never needs a horizontal bar, and an action that does not fit beside its keys wraps onto a second line. Every row gets the height the tallest wrapped action needs. --- crates/leanrows-win32/src/lib.rs | 2 +- crates/leanrows-win32/src/native.rs | 70 +- crates/leanrows-win32/src/native/scrollbar.rs | 829 ++++++++++++++++++ crates/leanrows-win32/src/native/settings.rs | 146 ++- 4 files changed, 1039 insertions(+), 8 deletions(-) create mode 100644 crates/leanrows-win32/src/native/scrollbar.rs diff --git a/crates/leanrows-win32/src/lib.rs b/crates/leanrows-win32/src/lib.rs index 93815fb..01b0260 100644 --- a/crates/leanrows-win32/src/lib.rs +++ b/crates/leanrows-win32/src/lib.rs @@ -15,7 +15,7 @@ mod model; mod native; #[cfg(not(windows))] mod native_stub; -#[allow(dead_code, reason = "the thin scroll bars use it in the next commit")] +#[cfg_attr(not(windows), allow(dead_code))] mod scroll; #[cfg_attr(not(windows), allow(dead_code))] mod shortcuts; diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 0505a85..d84418c 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -8,6 +8,7 @@ mod hover; mod layout; mod process; mod progress; +mod scrollbar; mod settings; mod sort_smoke; mod sorting; @@ -545,6 +546,7 @@ pub(crate) fn run(options: ShellOptions) -> Result { let instance = HINSTANCE(module.0); register_window_class(instance)?; settings::register_class(instance)?; + scrollbar::register_class(instance)?; let accelerators = load_accelerators(instance)?; let smoke_verified = Arc::new(AtomicBool::new(false)); let document_smoke_observed = Arc::new(AtomicU64::new(0)); @@ -1798,6 +1800,31 @@ fn verify_thin_scroll_bars(window: HWND) -> Result<(), ShellError> { "the wheel over the thin bar did not scroll the rows", )); } + // With the thin bars off, as in high contrast, the system bar and its + // width come back. + scrollbar::set_enabled(list, false); + let mut frame = RECT::default(); + let mut client = RECT::default(); + // SAFETY: The list is live and both rectangles are writable. + unsafe { + let _ = GetWindowRect(list, &raw mut frame); + let _ = GetClientRect(list, &raw mut client); + } + let system_kept = (frame.right - frame.left) - (client.right - client.left); + let bar_left = thin_bar_over(list, true).is_some(); + scrollbar::set_enabled(list, true); + // SAFETY: A metric query. + let system = unsafe { + windows::Win32::UI::HiDpi::GetSystemMetricsForDpi( + windows::Win32::UI::WindowsAndMessaging::SM_CXVSCROLL, + dpi, + ) + }; + if system_kept != system || bar_left { + return Err(ShellError::new(format!( + "with the thin bars off the rows keep {system_kept} px, not the system bar's {system} px, and a thin bar shows: {bar_left}" + ))); + } Ok(()) })(); set_rows(rows); @@ -3414,6 +3441,7 @@ fn apply_native_presentation(state: &WindowState) -> Result<(), ShellError> { let _ = SetWindowTheme(state.chrome.search_edit, visual_class, visual_subclass); let _ = SetWindowTheme(state.chrome.match_case, visual_class, visual_subclass); } + scrollbar::set_enabled(state.list, thin_scroll_bars(state.theme.effective)); let _ = state.theme.apply_window_chrome(state.window); apply_theme_button_text(state)?; invalidate_native_presentation(state); @@ -3456,7 +3484,8 @@ fn apply_settings_presentation(state: &WindowState) -> Result<(), ShellError> { set_control_text(panel.zoom_value, &format!("{}%", state.theme.zoom))?; } if !panel.shortcuts.0.is_null() { - settings::apply_row_height(panel.shortcuts, state.theme.metrics.table_row_height); + settings::fit_rows(panel.shortcuts, &state.theme); + scrollbar::set_enabled(panel.shortcuts, thin_scroll_bars(state.theme.effective)); let light = matches!(state.theme.effective, EffectiveTheme::Light); // SAFETY: The list is live; an empty class turns visual styles off so // a dark theme's colours are not covered by light chrome. @@ -3477,6 +3506,12 @@ fn apply_settings_presentation(state: &WindowState) -> Result<(), ShellError> { Ok(()) } +/// The lists draw thin bars in the theme's colours, except in high contrast, +/// where the system's own bars keep the system's colours. +const fn thin_scroll_bars(theme: EffectiveTheme) -> bool { + !matches!(theme, EffectiveTheme::HighContrast) +} + /// The theme button shows the theme's name; its tooltip and accessible name /// add what System resolves to and how to change it. fn apply_theme_button_text(state: &WindowState) -> Result<(), ShellError> { @@ -3701,7 +3736,10 @@ fn handle_zoom_wheel(window: HWND, message: &MSG) -> bool { 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)) + && (message.hwnd == state.list + || Some(message.hwnd) == list_header(state.list) + || scrollbar::bar_windows(state.list) + .is_some_and(|(down, across)| message.hwnd == down || message.hwnd == across)) } fn wheel_delta(message: &MSG) -> i32 { @@ -4814,6 +4852,18 @@ fn open_settings(window: HWND) { return; }; state.settings = handles; + if scrollbar::install( + state.settings.shortcuts, + window, + thin_scroll_bars(state.theme.effective), + ) + .is_err() + { + set_status( + window, + "The settings panel's scroll bar could not be created", + ); + } for button in state.settings.buttons() { let _ = hover::install(button, window); } @@ -5236,6 +5286,7 @@ fn create_children(window: HWND) -> Result<(), ()> { progress::install_subclass(progress, window).map_err(|_| ())?; let header = list_header(list).ok_or(())?; header::install_subclass(header, window).map_err(|_| ())?; + scrollbar::install(list, window, thin_scroll_bars(state.theme.effective)).map_err(|_| ())?; tabs::install_subclass(tab_strip, window).map_err(|_| ())?; // A new child goes below its older siblings; the bar was created first, // so it goes to the bottom, under the controls it holds. @@ -7332,6 +7383,21 @@ mod tests { } } + #[test] + fn high_contrast_keeps_the_system_scroll_bars() { + assert!(!super::thin_scroll_bars( + super::EffectiveTheme::HighContrast + )); + for theme in [ + super::EffectiveTheme::Light, + super::EffectiveTheme::Dark, + super::EffectiveTheme::Dim, + super::EffectiveTheme::Dusk, + ] { + assert!(super::thin_scroll_bars(theme), "{theme:?}"); + } + } + #[test] fn toolbar_buttons_use_leanmarks_button_styles() { use super::{ButtonFill, ButtonKind, button_fill}; diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs new file mode 100644 index 0000000..4322f5c --- /dev/null +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -0,0 +1,829 @@ +//! Thin, theme-coloured scroll bars for the list views. +//! +//! Each list keeps its own scroll bars and its own scroll state, so the +//! wheel, the keyboard, touch panning and UI Automation work as before. The +//! list's subclass narrows the room Windows keeps for those bars to a thin +//! strip, which pushes the system bars out of sight past the list's edge. +//! Two small windows laid over the strip draw the bars in the theme's +//! colours, without arrow buttons, and turn mouse input into the list's own +//! scroll messages. High contrast turns the overlay off, which gives the +//! system bars and their system colours back. + +use std::ffi::c_void; + +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; +use windows::Win32::Graphics::Gdi::{ + BeginPaint, DC_BRUSH, DC_PEN, EndPaint, FillRect, GetStockObject, InvalidateRect, + MapWindowPoints, PAINTSTRUCT, RoundRect, SelectObject, SetDCBrushColor, SetDCPenColor, +}; +use windows::Win32::UI::Controls::{ + LVIR_BOUNDS, LVM_GETHEADER, LVM_GETITEMRECT, LVM_GETTOPINDEX, LVM_SCROLL, WM_MOUSELEAVE, +}; +use windows::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetCapture, ReleaseCapture, SetCapture, TME_LEAVE, TRACKMOUSEEVENT, TrackMouseEvent, +}; +use windows::Win32::UI::Shell::{ + DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_STYLE, GWLP_ID, GWLP_USERDATA, + GetClientRect, GetParent, GetScrollInfo, GetWindowLongPtrW, GetWindowRect, HMENU, HWND_TOP, + IDC_ARROW, IsWindow, IsWindowVisible, KillTimer, LoadCursorW, MA_NOACTIVATE, RegisterClassExW, + SB_ENDSCROLL, SB_HORZ, SB_PAGEDOWN, SB_PAGEUP, SB_VERT, SCROLLINFO, SIF_ALL, SM_CXVSCROLL, + SM_CYHSCROLL, SWP_FRAMECHANGED, SWP_HIDEWINDOW, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_NOZORDER, SWP_SHOWWINDOW, SendMessageW, SetTimer, SetWindowLongPtrW, SetWindowPos, + WM_CAPTURECHANGED, WM_ERASEBKGND, WM_HSCROLL, WM_KEYDOWN, WM_LBUTTONDBLCLK, WM_LBUTTONDOWN, + WM_LBUTTONUP, WM_MOUSEACTIVATE, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCCALCSIZE, + WM_NCDESTROY, WM_PAINT, WM_SETREDRAW, WM_SIZE, WM_TIMER, WM_VSCROLL, WM_WINDOWPOSCHANGED, + WNDCLASSEXW, WS_CHILD, WS_CLIPSIBLINGS, WS_EX_NOACTIVATE, WS_HSCROLL, WS_VSCROLL, +}; +use windows::core::{PCWSTR, w}; + +use super::theme::ThemeResources; +use crate::ShellError; +use crate::scroll::{ScrollRange, Thumb, page_direction, position_at, thumb}; + +const SUBCLASS_ID: usize = 0x4c52_5342; +const CLASS_NAME: PCWSTR = w!("LeanRowsScrollBar"); +const VERTICAL_ID: isize = 1; +const HORIZONTAL_ID: isize = 2; +const REPEAT_TIMER: usize = 1; +const REPEAT_FIRST_MS: u32 = 400; +const REPEAT_MS: u32 = 50; + +/// The strip a bar takes along the list's edge, in device-independent pixels. +pub(super) const STRIP_DIP: i32 = 10; +const THUMB_DIP: i32 = 6; +const THUMB_HOT_DIP: i32 = 8; +const MIN_THUMB_DIP: i32 = 32; +const END_INSET_DIP: i32 = 2; + +/// Pixels for `dip` device-independent pixels at `dpi`. +#[must_use] +pub(super) fn pixels(dip: i32, dpi: u32) -> i32 { + let dpi = i64::from(dpi.max(96)); + i32::try_from((i64::from(dip) * dpi + 48) / 96).unwrap_or(dip) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Axis { + Vertical, + Horizontal, +} + +#[derive(Debug)] +struct Bar { + window: HWND, + axis: Axis, + range: ScrollRange, + /// Where the bar sits in the list parent's client coordinates. + place: RECT, + shown: bool, + /// The part of a vertical bar beside the column titles, which is not track. + lead: i32, + hot: bool, + /// While the thumb is dragged: how far into the thumb it was caught. + grab: Option, + /// While the track is held: the direction it pages, and where. + paging: i32, + held_at: i32, +} + +impl Bar { + fn new(window: HWND, axis: Axis) -> Self { + Self { + window, + axis, + range: ScrollRange::default(), + place: RECT::default(), + shown: false, + lead: 0, + hot: false, + grab: None, + paging: 0, + held_at: 0, + } + } +} + +/// One list's overlay: the list, the window whose state holds the theme, and +/// the two bars. +#[derive(Debug)] +struct Overlay { + list: HWND, + owner: HWND, + enabled: bool, + syncing: bool, + bars: [Bar; 2], +} + +/// Registers the bar window class once per process. +pub(super) fn register_class( + instance: windows::Win32::Foundation::HINSTANCE, +) -> Result<(), ShellError> { + let class = WNDCLASSEXW { + cbSize: u32::try_from(std::mem::size_of::()).unwrap_or_default(), + lpfnWndProc: Some(bar_proc), + hInstance: instance, + // SAFETY: IDC_ARROW is a predefined system cursor. + hCursor: unsafe { LoadCursorW(None, IDC_ARROW) }.unwrap_or_default(), + lpszClassName: CLASS_NAME, + ..Default::default() + }; + // SAFETY: The class structure and its static name outlive the call. + if unsafe { RegisterClassExW(&raw const class) } == 0 { + return Err(ShellError::new("scroll bar class registration failed")); + } + Ok(()) +} + +/// Lays the thin bars over `list`. `owner` is the top-level window whose +/// state holds the theme. The overlay is on unless the theme is high +/// contrast. +pub(super) fn install(list: HWND, owner: HWND, enabled: bool) -> Result<(), ShellError> { + // SAFETY: The list is a live child on this UI thread. + let parent = unsafe { GetParent(list) } + .map_err(|_| ShellError::new("the list has no parent for its scroll bars"))?; + // SAFETY: As above; the module handle is only read. + let instance = unsafe { windows::Win32::System::LibraryLoader::GetModuleHandleW(None) } + .map_err(|_| ShellError::new("scroll bars need the module handle"))?; + let create = |id: isize| { + // SAFETY: The class is registered; the parent and module belong to + // this thread. The bar starts hidden and is placed by `sync`. + unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE, + CLASS_NAME, + w!(""), + WS_CHILD | WS_CLIPSIBLINGS, + 0, + 0, + 0, + 0, + Some(parent), + Some(HMENU(std::ptr::without_provenance_mut::( + id.unsigned_abs(), + ))), + Some(windows::Win32::Foundation::HINSTANCE(instance.0)), + None, + ) + } + .map_err(|_| ShellError::new("a scroll bar window could not be created")) + }; + let vertical = create(VERTICAL_ID)?; + let horizontal = match create(HORIZONTAL_ID) { + Ok(horizontal) => horizontal, + Err(error) => { + // SAFETY: The bar was created above and holds no state yet. + let _ = unsafe { DestroyWindow(vertical) }; + return Err(error); + } + }; + let overlay = Box::into_raw(Box::new(Overlay { + list, + owner, + enabled, + syncing: false, + bars: [ + Bar::new(vertical, Axis::Vertical), + Bar::new(horizontal, Axis::Horizontal), + ], + })); + // SAFETY: The overlay outlives both bars and the list's subclass: the + // list's WM_NCDESTROY destroys the bars, then frees it. Each bar clears + // its own handle from it if it goes first. + unsafe { + SetWindowLongPtrW(vertical, GWLP_USERDATA, overlay as isize); + SetWindowLongPtrW(horizontal, GWLP_USERDATA, overlay as isize); + let style = GetWindowLongPtrW(list, GWL_STYLE); + SetWindowLongPtrW(list, GWL_STYLE, style | style_bit(WS_CLIPSIBLINGS)); + if !SetWindowSubclass(list, Some(list_proc), SUBCLASS_ID, overlay as usize).as_bool() { + let _ = DestroyWindow(vertical); + let _ = DestroyWindow(horizontal); + drop(Box::from_raw(overlay)); + return Err(ShellError::new("the list's scroll bar subclass failed")); + } + } + refit(list); + Ok(()) +} + +fn overlay_for(list: HWND) -> *mut Overlay { + let mut data = 0_usize; + // SAFETY: The query only reads the subclass table of a live list. + let found = + unsafe { GetWindowSubclass(list, Some(list_proc), SUBCLASS_ID, Some(&raw mut data)) }; + if found.as_bool() { + std::ptr::with_exposed_provenance_mut::(data) + } else { + std::ptr::null_mut() + } +} + +/// Turns the overlay on or off (off in high contrast) and repaints the bars +/// in the current theme. +pub(super) fn set_enabled(list: HWND, enabled: bool) { + let pointer = overlay_for(list); + // SAFETY: The overlay belongs to the live list on this UI thread. + let Some(overlay) = (unsafe { pointer.as_mut() }) else { + return; + }; + if overlay.enabled != enabled { + overlay.enabled = enabled; + refit(list); + } + sync(overlay); + for bar in &overlay.bars { + // SAFETY: The bar is live or null; a null handle is ignored. + let _ = unsafe { InvalidateRect(Some(bar.window), None, false) }; + } +} + +/// The vertical and horizontal bar windows of `list`, for the smoke checks. +#[must_use] +pub(super) fn bar_windows(list: HWND) -> Option<(HWND, HWND)> { + // SAFETY: As in `set_enabled`. + unsafe { overlay_for(list).as_ref() } + .map(|overlay| (overlay.bars[0].window, overlay.bars[1].window)) +} + +/// Asks Windows to measure the list's frame again. +fn refit(list: HWND) { + // SAFETY: The list is live; only its frame is recomputed. + let _ = unsafe { + SetWindowPos( + list, + None, + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + ) + }; +} + +fn is_sync_message(message: u32) -> bool { + matches!( + message, + WM_PAINT + | WM_WINDOWPOSCHANGED + | WM_SIZE + | WM_VSCROLL + | WM_HSCROLL + | WM_MOUSEWHEEL + | WM_MOUSEHWHEEL + | WM_KEYDOWN + | WM_SETREDRAW + ) || matches!(message, 0x1000..=0x10ff) +} + +unsafe extern "system" fn list_proc( + list: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + subclass_id: usize, + reference_data: usize, +) -> LRESULT { + let pointer = std::ptr::with_exposed_provenance_mut::(reference_data); + match message { + WM_NCCALCSIZE => { + // SAFETY: lparam carries the RECT or NCCALCSIZE_PARAMS of this + // message; both start with the proposed window rectangle. + let rect = lparam.0 as *mut RECT; + let proposed = unsafe { rect.as_ref() }.copied(); + // SAFETY: The list's own frame calculation runs first. + let result = unsafe { DefSubclassProc(list, message, wparam, lparam) }; + // SAFETY: The overlay outlives the subclass. + let enabled = unsafe { pointer.as_ref() }.is_some_and(|overlay| overlay.enabled); + if enabled && let (Some(proposed), Some(client)) = (proposed, unsafe { rect.as_mut() }) + { + narrow_frame(list, proposed, client); + } + result + } + WM_NCDESTROY => { + // SAFETY: This removes the exact pair being invoked; the overlay + // is freed after its bars, and the list still gets the message. + unsafe { + let _ = RemoveWindowSubclass(list, Some(list_proc), subclass_id); + if let Some(overlay) = pointer.as_mut() { + for bar in &mut overlay.bars { + if !bar.window.0.is_null() && IsWindow(Some(bar.window)).as_bool() { + SetWindowLongPtrW(bar.window, GWLP_USERDATA, 0); + let _ = DestroyWindow(bar.window); + } + bar.window = HWND::default(); + } + drop(Box::from_raw(pointer)); + } + DefSubclassProc(list, message, wparam, lparam) + } + } + _ => { + // SAFETY: Everything else stays with the list's own procedure. + let result = unsafe { DefSubclassProc(list, message, wparam, lparam) }; + if is_sync_message(message) { + // SAFETY: As above; `sync` guards against re-entry. + if let Some(overlay) = unsafe { pointer.as_mut() } { + sync(overlay); + } + } + result + } + } +} + +/// Gives the list its whole width and height back except a thin strip for +/// each bar it shows. Windows then draws its own bars from the client edge +/// outwards, past the window's edge where nothing shows. +fn narrow_frame(list: HWND, proposed: RECT, client: &mut RECT) { + // SAFETY: The list is live; style and DPI are only read. + let (style, dpi) = unsafe { (GetWindowLongPtrW(list, GWL_STYLE), GetDpiForWindow(list)) }; + let strip = pixels(STRIP_DIP, dpi); + if style & style_bit(WS_VSCROLL) != 0 { + // SAFETY: A metric query. + let system = unsafe { GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) }; + client.right = client + .right + .saturating_add(system) + .saturating_sub(strip) + .min(proposed.right.saturating_sub(strip)) + .max(client.left); + } + if style & style_bit(WS_HSCROLL) != 0 { + // SAFETY: A metric query. + let system = unsafe { GetSystemMetricsForDpi(SM_CYHSCROLL, dpi) }; + client.bottom = client + .bottom + .saturating_add(system) + .saturating_sub(strip) + .min(proposed.bottom.saturating_sub(strip)) + .max(client.top); + } +} + +fn style_bit(style: windows::Win32::UI::WindowsAndMessaging::WINDOW_STYLE) -> isize { + isize::try_from(style.0).unwrap_or_default() +} + +/// The signed low word of a mouse message's coordinates. +fn signed_word(value: isize) -> i32 { + let word = u16::try_from(value & 0xffff).unwrap_or_default(); + i32::from(i16::from_le_bytes(word.to_le_bytes())) +} + +fn range_of(list: HWND, axis: Axis) -> ScrollRange { + let mut info = SCROLLINFO { + cbSize: u32::try_from(std::mem::size_of::()).unwrap_or_default(), + fMask: SIF_ALL, + ..Default::default() + }; + let which = if axis == Axis::Vertical { + SB_VERT + } else { + SB_HORZ + }; + // SAFETY: The list is live and info is writable. + if unsafe { GetScrollInfo(list, which, &raw mut info) }.is_err() { + return ScrollRange::default(); + } + ScrollRange { + min: info.nMin, + max: info.nMax, + page: info.nPage, + pos: info.nPos, + } +} + +/// Places, shows or hides, and repaints the bars to match the list. Cheap +/// when nothing changed: two scroll queries and two rectangles. +fn sync(overlay: &mut Overlay) { + if overlay.syncing { + return; + } + overlay.syncing = true; + let list = overlay.list; + // SAFETY: The list is live; these calls only read it. + let (visible, style, dpi, parent) = unsafe { + ( + IsWindowVisible(list).as_bool(), + GetWindowLongPtrW(list, GWL_STYLE), + GetDpiForWindow(list), + GetParent(list).unwrap_or_default(), + ) + }; + let mut client = RECT::default(); + // SAFETY: As above; the rectangle is writable. + let _ = unsafe { GetClientRect(list, &raw mut client) }; + let mut origin = [POINT::default()]; + // SAFETY: Maps one point from the list to its parent. + unsafe { MapWindowPoints(Some(list), Some(parent), &mut origin) }; + let origin = origin[0]; + let strip = pixels(STRIP_DIP, dpi); + let vertical_shown = overlay.enabled && visible && style & style_bit(WS_VSCROLL) != 0; + let horizontal_shown = overlay.enabled && visible && style & style_bit(WS_HSCROLL) != 0; + let lead = header_height(list); + for bar in &mut overlay.bars { + let (shown, place) = match bar.axis { + Axis::Vertical => ( + vertical_shown, + RECT { + left: origin.x + client.right, + top: origin.y, + right: origin.x + client.right + strip, + bottom: origin.y + client.bottom + if horizontal_shown { strip } else { 0 }, + }, + ), + Axis::Horizontal => ( + horizontal_shown, + RECT { + left: origin.x, + top: origin.y + client.bottom, + right: origin.x + client.right, + bottom: origin.y + client.bottom + strip, + }, + ), + }; + let range = if shown { + range_of(list, bar.axis) + } else { + ScrollRange::default() + }; + let bar_lead = if bar.axis == Axis::Vertical { lead } else { 0 }; + if shown != bar.shown || place != bar.place { + let flags = SWP_NOACTIVATE + | if shown { + SWP_SHOWWINDOW + } else { + SWP_HIDEWINDOW + }; + // SAFETY: The bar is a live sibling of the list. + let _ = unsafe { + SetWindowPos( + bar.window, + Some(HWND_TOP), + place.left, + place.top, + place.right - place.left, + place.bottom - place.top, + flags, + ) + }; + bar.shown = shown; + bar.place = place; + bar.range = range; + bar.lead = bar_lead; + // SAFETY: As above. + let _ = unsafe { InvalidateRect(Some(bar.window), None, false) }; + } else if range != bar.range || bar_lead != bar.lead { + bar.range = range; + bar.lead = bar_lead; + // SAFETY: As above. + let _ = unsafe { InvalidateRect(Some(bar.window), None, false) }; + } + } + overlay.syncing = false; +} + +fn header_height(list: HWND) -> i32 { + // SAFETY: LVM_GETHEADER carries no pointers. + let header = HWND(unsafe { SendMessageW(list, LVM_GETHEADER, None, None) }.0 as *mut c_void); + // SAFETY: The header is live or null. + if header.0.is_null() || !unsafe { IsWindowVisible(header) }.as_bool() { + return 0; + } + let mut rect = RECT::default(); + // SAFETY: The rectangle is writable. + let _ = unsafe { GetWindowRect(header, &raw mut rect) }; + (rect.bottom - rect.top).max(0) +} + +/// The track a bar's thumb runs along: start and length in the bar's own +/// pixels along its axis. +fn track(bar: &Bar, dpi: u32) -> (i32, i32) { + let inset = pixels(END_INSET_DIP, dpi); + let length = match bar.axis { + Axis::Vertical => bar.place.bottom - bar.place.top - bar.lead, + Axis::Horizontal => bar.place.right - bar.place.left, + }; + let start = if bar.axis == Axis::Vertical { + bar.lead + } else { + 0 + } + inset; + (start, (length - inset * 2).max(0)) +} + +fn thumb_of(bar: &Bar, dpi: u32) -> Option<(i32, Thumb)> { + let (start, length) = track(bar, dpi); + thumb(bar.range, length, pixels(MIN_THUMB_DIP, dpi)).map(|thumb| (start, thumb)) +} + +fn along(bar: &Bar, lparam: LPARAM) -> i32 { + let x = signed_word(lparam.0); + let y = signed_word(lparam.0 >> 16); + if bar.axis == Axis::Vertical { y } else { x } +} + +/// Scrolls the list so its first visible unit is `pos`. +fn scroll_to(list: HWND, axis: Axis, pos: i32) { + let current = range_of(list, axis).pos; + let delta = i64::from(pos) - i64::from(current); + if delta == 0 { + return; + } + let (dx, dy) = match axis { + Axis::Horizontal => (delta, 0), + // Report view scrolls whole rows: pixels are rows times row height. + Axis::Vertical => (0, delta * i64::from(row_height(list))), + }; + let clamp = + |value: i64| i32::try_from(value).unwrap_or(if value < 0 { i32::MIN } else { i32::MAX }); + // SAFETY: LVM_SCROLL carries two pixel counts. + unsafe { + SendMessageW( + list, + LVM_SCROLL, + Some(WPARAM( + isize::try_from(clamp(dx)) + .unwrap_or_default() + .cast_unsigned(), + )), + Some(LPARAM(clamp(dy) as isize)), + ); + } +} + +fn row_height(list: HWND) -> i32 { + // SAFETY: LVM_GETTOPINDEX carries no pointers. + let top = unsafe { SendMessageW(list, LVM_GETTOPINDEX, None, None) }.0; + let mut rect = RECT { + left: i32::try_from(LVIR_BOUNDS).unwrap_or_default(), + ..Default::default() + }; + // SAFETY: The rectangle is writable and outlives the synchronous call. + unsafe { + SendMessageW( + list, + LVM_GETITEMRECT, + Some(WPARAM(top.max(0).unsigned_abs())), + Some(LPARAM((&raw mut rect) as isize)), + ); + } + (rect.bottom - rect.top).max(1) +} + +fn page(list: HWND, axis: Axis, direction: i32) { + let message = if axis == Axis::Vertical { + WM_VSCROLL + } else { + WM_HSCROLL + }; + let code = if direction < 0 { + SB_PAGEUP.0 + } else { + SB_PAGEDOWN.0 + }; + // SAFETY: Scroll codes carry no pointers. + unsafe { + SendMessageW( + list, + message, + Some(WPARAM(usize::try_from(code).unwrap_or_default())), + Some(LPARAM(0)), + ); + SendMessageW( + list, + message, + Some(WPARAM(SB_ENDSCROLL.0 as usize)), + Some(LPARAM(0)), + ); + } +} + +#[allow( + clippy::too_many_lines, + reason = "one window procedure handles the bar's paint and mouse in one place" +)] +unsafe extern "system" fn bar_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + // SAFETY: The pointer slot holds the overlay or zero (see `install`). + let pointer = unsafe { GetWindowLongPtrW(window, GWLP_USERDATA) } as *mut Overlay; + // SAFETY: The overlay lives on this UI thread for as long as the slot + // holds it. + let Some(overlay) = (unsafe { pointer.as_mut() }) else { + // SAFETY: Without an overlay the bar is a plain window. + return unsafe { DefWindowProcW(window, message, wparam, lparam) }; + }; + // SAFETY: The control id is read from the live bar. + let index = usize::from(unsafe { GetWindowLongPtrW(window, GWLP_ID) } == HORIZONTAL_ID); + let list = overlay.list; + // SAFETY: The list is live while its overlay is. + let dpi = unsafe { GetDpiForWindow(list) }; + match message { + WM_PAINT => { + paint(overlay, index, dpi); + LRESULT(0) + } + WM_ERASEBKGND => LRESULT(1), + WM_MOUSEACTIVATE => LRESULT(isize::try_from(MA_NOACTIVATE).unwrap_or_default()), + WM_LBUTTONDOWN | WM_LBUTTONDBLCLK => { + let bar = &mut overlay.bars[index]; + let at = along(bar, lparam); + if let Some((start, thumb)) = thumb_of(bar, dpi) { + let at = at - start; + match page_direction(thumb, at) { + 0 => bar.grab = Some(at - thumb.start), + direction => { + bar.paging = direction; + bar.held_at = at; + page(list, bar.axis, direction); + // SAFETY: The timer belongs to this bar. + unsafe { SetTimer(Some(window), REPEAT_TIMER, REPEAT_FIRST_MS, None) }; + } + } + // SAFETY: The bar takes the mouse until the button goes up. + unsafe { SetCapture(window) }; + // SAFETY: As above. + let _ = unsafe { InvalidateRect(Some(window), None, false) }; + } + LRESULT(0) + } + WM_MOUSEMOVE => { + let bar = &mut overlay.bars[index]; + if !bar.hot { + bar.hot = true; + let mut track = TRACKMOUSEEVENT { + cbSize: u32::try_from(std::mem::size_of::()) + .unwrap_or_default(), + dwFlags: TME_LEAVE, + hwndTrack: window, + dwHoverTime: 0, + }; + // SAFETY: The structure outlives the call. + let _ = unsafe { TrackMouseEvent(&raw mut track) }; + // SAFETY: As above. + let _ = unsafe { InvalidateRect(Some(window), None, false) }; + } + let at = along(bar, lparam); + if let Some(grab) = bar.grab + && let Some((start, thumb)) = thumb_of(bar, dpi) + { + let (_, length) = track(bar, dpi); + let pos = position_at(bar.range, length, thumb.length, at - start - grab); + scroll_to(list, bar.axis, pos); + } else if bar.paging != 0 { + bar.held_at = at - track(bar, dpi).0; + } + LRESULT(0) + } + WM_TIMER if wparam.0 == REPEAT_TIMER => { + let bar = &mut overlay.bars[index]; + let direction = bar.paging; + let still = direction != 0 + && thumb_of(bar, dpi) + .is_some_and(|(_, thumb)| page_direction(thumb, bar.held_at) == direction); + if still { + page(list, bar.axis, direction); + // SAFETY: The timer belongs to this bar. + unsafe { SetTimer(Some(window), REPEAT_TIMER, REPEAT_MS, None) }; + } else { + // SAFETY: As above. + let _ = unsafe { KillTimer(Some(window), REPEAT_TIMER) }; + } + LRESULT(0) + } + WM_LBUTTONUP | WM_CAPTURECHANGED => { + let bar = &mut overlay.bars[index]; + bar.grab = None; + bar.paging = 0; + // SAFETY: The timer and capture belong to this bar. + unsafe { + let _ = KillTimer(Some(window), REPEAT_TIMER); + if message == WM_LBUTTONUP && GetCapture() == window { + let _ = ReleaseCapture(); + } + let _ = InvalidateRect(Some(window), None, false); + } + LRESULT(0) + } + WM_MOUSELEAVE => { + overlay.bars[index].hot = false; + // SAFETY: As above. + let _ = unsafe { InvalidateRect(Some(window), None, false) }; + LRESULT(0) + } + WM_MOUSEWHEEL | WM_MOUSEHWHEEL => { + // SAFETY: The wheel goes to the list as if it were over it. + unsafe { SendMessageW(list, message, Some(wparam), Some(lparam)) } + } + WM_NCDESTROY => { + overlay.bars[index].window = HWND::default(); + // SAFETY: The bar no longer reaches the overlay. + unsafe { + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + DefWindowProcW(window, message, wparam, lparam) + } + } + // SAFETY: Everything else is a plain window's. + _ => unsafe { DefWindowProcW(window, message, wparam, lparam) }, + } +} + +fn paint(overlay: &Overlay, index: usize, dpi: u32) { + let bar = &overlay.bars[index]; + let window = bar.window; + let mut paint = PAINTSTRUCT::default(); + // SAFETY: The bar is handling WM_PAINT; paint is writable until EndPaint. + let device = unsafe { BeginPaint(window, &raw mut paint) }; + let state = super::state_pointer_for(overlay.owner); + // SAFETY: The owner's state outlives its child windows' paints. + if let Some(state) = unsafe { state.as_ref() } + && !device.0.is_null() + { + draw(device, bar, &state.theme, dpi); + } + // SAFETY: Closes the BeginPaint above. + let _ = unsafe { EndPaint(window, &raw const paint) }; +} + +fn draw(device: windows::Win32::Graphics::Gdi::HDC, bar: &Bar, theme: &ThemeResources, dpi: u32) { + let width = bar.place.right - bar.place.left; + let height = bar.place.bottom - bar.place.top; + let whole = RECT { + left: 0, + top: 0, + right: width, + bottom: height, + }; + let palette = theme.palette; + // SAFETY: The theme's brushes outlive this paint; the stock DC brush and + // pen are always valid. + unsafe { + FillRect(device, &raw const whole, theme.brushes.surface()); + if bar.lead > 0 { + let lead = RECT { + bottom: bar.lead.min(height), + ..whole + }; + FillRect(device, &raw const lead, theme.brushes.surface_muted()); + let rule = RECT { + top: lead.bottom - theme.metrics.border_width, + ..lead + }; + FillRect(device, &raw const rule, theme.brushes.border()); + } + let Some((start, thumb)) = thumb_of(bar, dpi) else { + return; + }; + let active = bar.hot || bar.grab.is_some(); + let thickness = pixels(if active { THUMB_HOT_DIP } else { THUMB_DIP }, dpi); + let colour = if active { + palette.text_secondary + } else { + palette.text_disabled + }; + let across = match bar.axis { + Axis::Vertical => width, + Axis::Horizontal => height, + }; + let near = (across - thickness) / 2; + let (from, to) = (start + thumb.start, start + thumb.end()); + let rect = match bar.axis { + Axis::Vertical => RECT { + left: near, + top: from, + right: near + thickness, + bottom: to, + }, + Axis::Horizontal => RECT { + left: from, + top: near, + right: to, + bottom: near + thickness, + }, + }; + let previous_brush = SelectObject(device, GetStockObject(DC_BRUSH)); + let previous_pen = SelectObject(device, GetStockObject(DC_PEN)); + SetDCBrushColor(device, colour.colorref()); + SetDCPenColor(device, colour.colorref()); + let _ = RoundRect( + device, + rect.left, + rect.top, + rect.right, + rect.bottom, + thickness, + thickness, + ); + SelectObject(device, previous_pen); + SelectObject(device, previous_brush); + } +} diff --git a/crates/leanrows-win32/src/native/settings.rs b/crates/leanrows-win32/src/native/settings.rs index 51e6bae..acf2c61 100644 --- a/crates/leanrows-win32/src/native/settings.rs +++ b/crates/leanrows-win32/src/native/settings.rs @@ -432,8 +432,8 @@ pub(super) fn layout(handles: &SettingsHandles, bounds: UiRect, theme: &ThemeRes y = y.saturating_add(label_height).saturating_add(m.space_1); let list_height = bounds.height.saturating_sub(y).saturating_sub(m.space_6); place(handles.shortcuts, left, y, width, list_height); - let keys_width = width.saturating_mul(9) / 20; - for (column, column_width) in [(0, keys_width), (1, width.saturating_sub(keys_width))] { + let row_width = shortcut_row_width(width, m.dpi); + for (column, column_width) in [(0, row_width), (1, 0)] { // SAFETY: LVM_SETCOLUMNWIDTH carries a column index and a width. unsafe { SendMessageW( @@ -444,6 +444,7 @@ pub(super) fn layout(handles: &SettingsHandles, bounds: UiRect, theme: &ThemeRes ); } } + fit_rows(handles.shortcuts, theme); // SAFETY: The list colour message carries a COLORREF value only. unsafe { SendMessageW( @@ -556,15 +557,20 @@ pub(super) fn draw_shortcut(theme: &ThemeResources, list: HWND, lparam: LPARAM) ); SelectObject(device, HGDIOBJ(theme.fonts.body().0)); SetTextColor(device, palette.text_secondary.colorref()); + let left = key_box.right.saturating_add(m.space_3); + let height = action_height(device, &mut action[..action_length], row.right - left); + let top = row.top + (row.bottom - row.top - height) / 2; let mut action_rect = RECT { - left: key_box.right.saturating_add(m.space_3), - ..row + left, + top, + right: row.right, + bottom: top + height, }; DrawTextW( device, &mut action[..action_length], &raw mut action_rect, - DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS, + ACTION_FORMAT, ); let rule = RECT { top: row.bottom.saturating_sub(m.border_width.max(1)), @@ -578,6 +584,136 @@ pub(super) fn draw_shortcut(theme: &ThemeResources, list: HWND, lparam: LPARAM) )) } +/// The shortcut rows' width in a list `width` pixels wide: all of it but the +/// room a vertical bar takes, so no row runs under the bar and none needs a +/// horizontal bar. The list decides on a horizontal bar as if its vertical +/// bar were the system's width, so the rows leave that much, which is more +/// than the thin bar's strip. +#[must_use] +pub(super) fn shortcut_row_width(width: i32, dpi: u32) -> i32 { + // SAFETY: A metric query. + let system = unsafe { + windows::Win32::UI::HiDpi::GetSystemMetricsForDpi( + windows::Win32::UI::WindowsAndMessaging::SM_CXVSCROLL, + dpi, + ) + }; + let strip = super::scrollbar::pixels(super::scrollbar::STRIP_DIP, dpi); + width.saturating_sub(system.max(strip)).max(0) +} + +const ACTION_FORMAT: windows::Win32::Graphics::Gdi::DRAW_TEXT_FORMAT = + windows::Win32::Graphics::Gdi::DRAW_TEXT_FORMAT( + DT_RIGHT.0 | windows::Win32::Graphics::Gdi::DT_WORDBREAK.0 | DT_NOPREFIX.0, + ); + +/// The height `text` takes wrapped at `width` pixels in the font selected +/// into `device`. +fn action_height(device: HDC, text: &mut [u16], width: i32) -> i32 { + let mut rect = RECT { + right: width.max(1), + ..Default::default() + }; + // SAFETY: The rectangle and the text are writable for the call. + unsafe { + DrawTextW( + device, + text, + &raw mut rect, + ACTION_FORMAT | windows::Win32::Graphics::Gdi::DT_CALCRECT, + ); + } + rect.bottom - rect.top +} + +/// The height each shortcut's row needs, in reading order, when rows are +/// `row_width` pixels wide: the action wrapped beside its keys' box, with +/// the usual padding above and below, and never less than a table row. +pub(super) fn shortcut_row_heights(list: HWND, theme: &ThemeResources, row_width: i32) -> Vec { + let m = theme.metrics; + // SAFETY: The list is live; the screen-compatible DC is released below. + let device = unsafe { windows::Win32::Graphics::Gdi::GetDC(Some(list)) }; + if device.0.is_null() { + return Vec::new(); + } + let mut fits = Vec::with_capacity(SHORTCUTS.len()); + for entry in &SHORTCUTS { + let keys: Vec = entry.keys.encode_utf16().collect(); + let mut action: Vec = entry.action.encode_utf16().collect(); + let mut size = windows::Win32::Foundation::SIZE::default(); + // SAFETY: The fonts outlive the DC; the buffers are local. + unsafe { + SelectObject(device, HGDIOBJ(theme.fonts.caption().0)); + let _ = + windows::Win32::Graphics::Gdi::GetTextExtentPoint32W(device, &keys, &raw mut size); + SelectObject(device, HGDIOBJ(theme.fonts.body().0)); + } + let keys_box = size.cx.saturating_add(m.space_1.saturating_mul(2)); + let action_room = row_width - keys_box - m.space_3; + let mut rect = RECT { + right: action_room.max(1), + ..Default::default() + }; + // SAFETY: As in `action_height`. + unsafe { + DrawTextW( + device, + &mut action, + &raw mut rect, + ACTION_FORMAT | windows::Win32::Graphics::Gdi::DT_CALCRECT, + ); + } + fits.push( + m.table_row_height + .max((rect.bottom - rect.top).saturating_add(m.space_2.saturating_mul(2))), + ); + } + // SAFETY: Releases the DC taken above. + unsafe { + windows::Win32::Graphics::Gdi::ReleaseDC(Some(list), device); + } + fits +} + +/// Gives every row the height the tallest wrapped action needs, and leaves +/// the rows alone when they already have it. +pub(super) fn fit_rows(list: HWND, theme: &ThemeResources) { + let mut rect = RECT::default(); + // SAFETY: The list is live and the rectangle writable. + if unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowRect(list, &raw mut rect) } + .is_err() + { + return; + } + let row_width = shortcut_row_width(rect.right - rect.left, theme.metrics.dpi); + let height = shortcut_row_heights(list, theme, row_width) + .into_iter() + .max() + .unwrap_or(theme.metrics.table_row_height); + if row_height(list) != height { + apply_row_height(list, height); + } +} + +/// The height of the list's rows now. +#[must_use] +pub(super) fn row_height(list: HWND) -> i32 { + let mut rect = RECT { + left: i32::try_from(windows::Win32::UI::Controls::LVIR_BOUNDS).unwrap_or_default(), + ..Default::default() + }; + // SAFETY: The rectangle outlives the synchronous call. + unsafe { + SendMessageW( + list, + windows::Win32::UI::Controls::LVM_GETITEMRECT, + Some(WPARAM(0)), + Some(LPARAM((&raw mut rect) as isize)), + ); + } + rect.bottom - rect.top +} + /// Reads one cell's text and returns the row's item data. fn item_text(list: HWND, index: i32, subitem: i32, out: &mut [u16]) -> isize { let mut item = LVITEMW { From 7d2b098ce5cd50b45f000c1102428ccb9820de77 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:17:52 -0600 Subject: [PATCH 03/10] Cover sibling clipping and Ctrl+wheel over the thin bar The scroll bar smoke now checks that the rows clip their siblings, so they never paint over the bars lying on them, and that Ctrl+wheel over the rows' bar zooms one step, as it does over the rows. --- crates/leanrows-win32/src/native.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index d84418c..517ca10 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1800,6 +1800,31 @@ fn verify_thin_scroll_bars(window: HWND) -> Result<(), ShellError> { "the wheel over the thin bar did not scroll the rows", )); } + // The rows do not paint over the bars that lie on them. + // SAFETY: The list is live; its style is only read. + let style = unsafe { GetWindowLongPtrW(list, GWL_STYLE) }; + if style & isize::try_from(WS_CLIPSIBLINGS.0).unwrap_or_default() == 0 { + return Err(ShellError::new("the rows paint over their thin bars")); + } + // Ctrl+wheel over the bar zooms the rows, as it does over them. + // SAFETY: As above. + let original = unsafe { pointer.as_ref() }.map_or(0, |state| state.theme.zoom); + let notch = MSG { + hwnd: bar, + message: WM_MOUSEWHEEL, + wParam: WPARAM((120_usize << 16) | usize::try_from(MK_CONTROL.0).unwrap_or_default()), + ..Default::default() + }; + let handled = handle_zoom_wheel(window, ¬ch); + // SAFETY: As above. + let zoomed = unsafe { pointer.as_ref() }.map_or(0, |state| state.theme.zoom); + set_grid_zoom(window, original)?; + if !handled || zoomed != crate::zoom::step_zoom(original, 1) { + return Err(ShellError::new(format!( + "Ctrl+wheel over the thin bar left the rows at {zoomed}%, not {}%", + crate::zoom::step_zoom(original, 1) + ))); + } // With the thin bars off, as in high contrast, the system bar and its // width come back. scrollbar::set_enabled(list, false); From e069c189d113aea8daef8644a45d819fc9b3e525 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:29:13 -0600 Subject: [PATCH 04/10] Check the shortcut rows' height, also after a DPI change The smoke now fails when the rows are taller than the tallest wrapped action needs, and runs the shortcut checks again after rebuilding the theme at 150% DPI in the order a WM_DPICHANGED refresh uses. Without the fit in the panel's layout the rows stay 150 px high where 74 px is needed. --- crates/leanrows-win32/src/native.rs | 47 +++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 517ca10..c45373c 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1862,6 +1862,10 @@ fn verify_thin_scroll_bars(window: HWND) -> Result<(), ShellError> { /// The settings panel's shortcut list is no wider than its client area, /// has no horizontal bar, and every action fits whole in its row beside its /// keys, on as many lines as it needs. +#[allow( + clippy::too_many_lines, + reason = "one check run at two DPIs reads best as one pass" +)] fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { use windows::Win32::Graphics::Gdi::{DT_CALCRECT, DT_NOPREFIX, DT_WORDBREAK}; use windows::Win32::UI::Controls::{LVIR_BOUNDS, LVM_GETITEMRECT}; @@ -1876,7 +1880,7 @@ fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { ); } let pointer = state_pointer_for(window); - let verification = (|| { + let check = |at: &str| -> Result<(), ShellError> { // SAFETY: As above. let state = unsafe { pointer.as_ref() } .ok_or_else(|| ShellError::new("shortcut smoke state is unavailable"))?; @@ -1915,6 +1919,7 @@ fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { // SAFETY: The list is live; the DC is released below. let device = unsafe { GetDC(Some(list)) }; let mut problem = None; + let mut tallest = m.table_row_height; for entry in &crate::shortcuts::SHORTCUTS { let keys: Vec = entry.keys.encode_utf16().collect(); let mut action: Vec = entry.action.encode_utf16().collect(); @@ -1936,6 +1941,7 @@ fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { } let fits_across = wrapped.right <= room - size.cx; let fits_down = wrapped.bottom - wrapped.top + m.space_2 * 2 <= row.bottom - row.top; + tallest = tallest.max(wrapped.bottom - wrapped.top + m.space_2 * 2); if !(fits_across && fits_down) && problem.is_none() { problem = Some(format!( "\"{}\" needs {} x {} px beside \"{}\" in a row with {} x {} px", @@ -1950,8 +1956,27 @@ fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { } // SAFETY: Releases the DC taken above. unsafe { ReleaseDC(Some(list), device) }; - problem.map_or(Ok(()), |problem| Err(ShellError::new(problem))) - })(); + // A report list makes a row one pixel taller than its image list. + if problem.is_none() && row.bottom - row.top > tallest + 1 { + problem = Some(format!( + "the shortcut rows are {} px high, but the tallest action needs {tallest} px", + row.bottom - row.top + )); + } + problem.map_or(Ok(()), |problem| { + Err(ShellError::new(format!("{at}{problem}"))) + }) + }; + // A DPI change gives the panel a new width and new fonts; the rows + // must be fitted again to the width the panel gets. + // SAFETY: As above. + let dpi = unsafe { pointer.as_ref() }.map_or(96, |state| state.theme.metrics.dpi); + let verification = check("").and_then(|()| { + rescale_for_smoke(window, dpi.saturating_mul(3) / 2)?; + let scaled = check("after a change to 150% DPI, "); + rescale_for_smoke(window, dpi)?; + scaled + }); // SAFETY: As above. unsafe { SendMessageW( @@ -1964,6 +1989,22 @@ fn verify_shortcut_list_fits(window: HWND) -> Result<(), ShellError> { verification } +/// Rebuilds the theme at `dpi` and lays the window out again, in the order +/// a `WM_DPICHANGED` refresh uses: presentation first, then layout. +fn rescale_for_smoke(window: HWND, dpi: u32) -> Result<(), ShellError> { + let pointer = state_pointer_for(window); + // SAFETY: Smoke checks run on the window's UI thread. + let state = unsafe { pointer.as_mut() } + .ok_or_else(|| ShellError::new("rescale smoke state is unavailable"))?; + let next = ThemeResources::new(state.theme.preference, dpi, state.theme.zoom)?; + let previous = std::mem::replace(&mut state.theme, next); + let result = apply_native_presentation(state); + drop(previous); + result?; + layout_children(window); + Ok(()) +} + fn font_height(font: windows::Win32::Graphics::Gdi::HFONT) -> Option { let mut definition = windows::Win32::Graphics::Gdi::LOGFONTW::default(); let size = i32::try_from(size_of::()).ok()?; From ada7a0badbcfcab0edd52bcefd464b4cc522a3d2 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:40:44 -0600 Subject: [PATCH 05/10] Check that list queries do not update the thin bars During a 10M-row Page Down run the list got LVM_GETHEADER and the undocumented 0x10C1 about 35 and 32 times per page, and every one of them updated the bars. The new unit test fails on the branch because every message in the LVM_ range counts as a change. --- crates/leanrows-win32/src/native/scrollbar.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index 4322f5c..5356f93 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -827,3 +827,48 @@ fn draw(device: windows::Win32::Graphics::Gdi::HDC, bar: &Bar, theme: &ThemeReso SelectObject(device, previous_brush); } } + +#[cfg(test)] +mod tests { + use super::is_sync_message; + use windows::Win32::UI::Controls::{ + LVM_DELETEALLITEMS, LVM_DELETECOLUMN, LVM_ENSUREVISIBLE, LVM_GETCOUNTPERPAGE, + LVM_GETHEADER, LVM_GETITEMCOUNT, LVM_GETITEMRECT, LVM_GETTOPINDEX, LVM_INSERTCOLUMNW, + LVM_SCROLL, LVM_SETCOLUMNWIDTH, LVM_SETITEMCOUNT, + }; + + /// While a 10M-row file pages down, the list is asked for its header and + /// sends itself the undocumented 0x10C1 about 35 and 32 times per page. + /// Queries change nothing the bars show, so they must not update them. + #[test] + fn list_queries_do_not_move_the_bars() { + for query in [ + LVM_GETHEADER, + LVM_GETTOPINDEX, + LVM_GETITEMRECT, + LVM_GETITEMCOUNT, + LVM_GETCOUNTPERPAGE, + 0x10c1, + ] { + assert!(!is_sync_message(query), "{query:#06x} moved the bars"); + } + } + + #[test] + fn list_changes_move_the_bars() { + for change in [ + LVM_SETITEMCOUNT, + LVM_SCROLL, + LVM_ENSUREVISIBLE, + LVM_SETCOLUMNWIDTH, + LVM_INSERTCOLUMNW, + LVM_DELETECOLUMN, + LVM_DELETEALLITEMS, + ] { + assert!( + is_sync_message(change), + "{change:#06x} did not move the bars" + ); + } + } +} From 3021d2b3bacd84c81f153ea9839aa1efaad53aec Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:02:08 -0600 Subject: [PATCH 06/10] Update the thin bars only after list messages that change it List queries such as LVM_GETHEADER, and the undocumented 0x10C1 the list sends itself while it paints, no longer update the bars. On a 10M-row file Page Down went from 21.6 to 20.4 ms (main: 19.4 ms). --- crates/leanrows-win32/src/native/scrollbar.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index 5356f93..afbb945 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -17,7 +17,9 @@ use windows::Win32::Graphics::Gdi::{ MapWindowPoints, PAINTSTRUCT, RoundRect, SelectObject, SetDCBrushColor, SetDCPenColor, }; use windows::Win32::UI::Controls::{ - LVIR_BOUNDS, LVM_GETHEADER, LVM_GETITEMRECT, LVM_GETTOPINDEX, LVM_SCROLL, WM_MOUSELEAVE, + LVIR_BOUNDS, LVM_DELETEALLITEMS, LVM_DELETECOLUMN, LVM_ENSUREVISIBLE, LVM_GETHEADER, + LVM_GETITEMRECT, LVM_GETTOPINDEX, LVM_INSERTCOLUMNW, LVM_SCROLL, LVM_SETCOLUMNWIDTH, + LVM_SETEXTENDEDLISTVIEWSTYLE, LVM_SETIMAGELIST, LVM_SETITEMCOUNT, WM_MOUSELEAVE, }; use windows::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; use windows::Win32::UI::Input::KeyboardAndMouse::{ @@ -264,6 +266,10 @@ fn refit(list: HWND) { }; } +/// The messages after which the bars may need to move or show a new range: +/// paints, size and scroll changes, and the list messages that change its +/// rows, columns or scroll position. List queries are left out; the list +/// gets dozens of them on every paint. fn is_sync_message(message: u32) -> bool { matches!( message, @@ -276,7 +282,16 @@ fn is_sync_message(message: u32) -> bool { | WM_MOUSEHWHEEL | WM_KEYDOWN | WM_SETREDRAW - ) || matches!(message, 0x1000..=0x10ff) + | LVM_SETITEMCOUNT + | LVM_SCROLL + | LVM_ENSUREVISIBLE + | LVM_SETCOLUMNWIDTH + | LVM_INSERTCOLUMNW + | LVM_DELETECOLUMN + | LVM_DELETEALLITEMS + | LVM_SETIMAGELIST + | LVM_SETEXTENDEDLISTVIEWSTYLE + ) } unsafe extern "system" fn list_proc( From 6ed0d05166bea44c239403a5e171f620d67f97e4 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:02:58 -0600 Subject: [PATCH 07/10] Check that the rows' messages reach the bars' code directly A list with an empty common controls subclass and nothing else took 20.6 ms per Page Down on a 10M-row file, against 19.6 ms on main and 19.7 ms with a plain window procedure swap. The smoke now fails while the bars use SetWindowSubclass on the rows. --- crates/leanrows-win32/src/native.rs | 7 +++++ crates/leanrows-win32/src/native/scrollbar.rs | 28 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index c45373c..c92e9c7 100644 --- a/crates/leanrows-win32/src/native.rs +++ b/crates/leanrows-win32/src/native.rs @@ -1806,6 +1806,13 @@ fn verify_thin_scroll_bars(window: HWND) -> Result<(), ShellError> { if style & isize::try_from(WS_CLIPSIBLINGS.0).unwrap_or_default() == 0 { return Err(ShellError::new("the rows paint over their thin bars")); } + // The rows' messages reach the bars' code directly. Common controls' + // subclass dispatcher in between made a 10M-row Page Down 1 ms slower. + if !scrollbar::is_direct_procedure(list) { + return Err(ShellError::new( + "the rows' messages go through the common controls subclass dispatcher", + )); + } // Ctrl+wheel over the bar zooms the rows, as it does over them. // SAFETY: As above. let original = unsafe { pointer.as_ref() }.map_or(0, |state| state.theme.zoom); diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index afbb945..18e01ac 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -30,15 +30,16 @@ use windows::Win32::UI::Shell::{ }; use windows::Win32::UI::WindowsAndMessaging::{ CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_STYLE, GWLP_ID, GWLP_USERDATA, - GetClientRect, GetParent, GetScrollInfo, GetWindowLongPtrW, GetWindowRect, HMENU, HWND_TOP, - IDC_ARROW, IsWindow, IsWindowVisible, KillTimer, LoadCursorW, MA_NOACTIVATE, RegisterClassExW, - SB_ENDSCROLL, SB_HORZ, SB_PAGEDOWN, SB_PAGEUP, SB_VERT, SCROLLINFO, SIF_ALL, SM_CXVSCROLL, - SM_CYHSCROLL, SWP_FRAMECHANGED, SWP_HIDEWINDOW, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, - SWP_NOZORDER, SWP_SHOWWINDOW, SendMessageW, SetTimer, SetWindowLongPtrW, SetWindowPos, - WM_CAPTURECHANGED, WM_ERASEBKGND, WM_HSCROLL, WM_KEYDOWN, WM_LBUTTONDBLCLK, WM_LBUTTONDOWN, - WM_LBUTTONUP, WM_MOUSEACTIVATE, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCCALCSIZE, - WM_NCDESTROY, WM_PAINT, WM_SETREDRAW, WM_SIZE, WM_TIMER, WM_VSCROLL, WM_WINDOWPOSCHANGED, - WNDCLASSEXW, WS_CHILD, WS_CLIPSIBLINGS, WS_EX_NOACTIVATE, WS_HSCROLL, WS_VSCROLL, + GWLP_WNDPROC, GetClientRect, GetParent, GetScrollInfo, GetWindowLongPtrW, GetWindowRect, HMENU, + HWND_TOP, IDC_ARROW, IsWindow, IsWindowVisible, KillTimer, LoadCursorW, MA_NOACTIVATE, + RegisterClassExW, SB_ENDSCROLL, SB_HORZ, SB_PAGEDOWN, SB_PAGEUP, SB_VERT, SCROLLINFO, SIF_ALL, + SM_CXVSCROLL, SM_CYHSCROLL, SWP_FRAMECHANGED, SWP_HIDEWINDOW, SWP_NOACTIVATE, SWP_NOMOVE, + SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, SendMessageW, SetTimer, SetWindowLongPtrW, + SetWindowPos, WM_CAPTURECHANGED, WM_ERASEBKGND, WM_HSCROLL, WM_KEYDOWN, WM_LBUTTONDBLCLK, + WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEACTIVATE, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, + WM_NCCALCSIZE, WM_NCDESTROY, WM_PAINT, WM_SETREDRAW, WM_SIZE, WM_TIMER, WM_VSCROLL, + WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_CHILD, WS_CLIPSIBLINGS, WS_EX_NOACTIVATE, WS_HSCROLL, + WS_VSCROLL, }; use windows::core::{PCWSTR, w}; @@ -250,6 +251,15 @@ pub(super) fn bar_windows(list: HWND) -> Option<(HWND, HWND)> { .map(|overlay| (overlay.bars[0].window, overlay.bars[1].window)) } +/// Whether `list`'s window procedure is the overlay's own, with nothing in +/// between, for the smoke checks. +#[must_use] +pub(super) fn is_direct_procedure(list: HWND) -> bool { + // SAFETY: Reads the live list's window procedure. + let current = unsafe { GetWindowLongPtrW(list, GWLP_WNDPROC) }; + current.cast_unsigned() == (list_proc as *const ()).addr() +} + /// Asks Windows to measure the list's frame again. fn refit(list: HWND) { // SAFETY: The list is live; only its frame is recomputed. From 47f4909e480ea954b0c97bc8f52385b27cab05a8 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:04:54 -0600 Subject: [PATCH 08/10] Swap the rows' window procedure instead of subclassing them SetWindowSubclass sends every list message through common controls' dispatcher, and the list gets dozens per paint. A plain GWLP_WNDPROC swap passes them on with CallWindowProcW. The list's procedure is put back on WM_NCDESTROY. --- crates/leanrows-win32/src/native/scrollbar.rs | 102 +++++++++++------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index 18e01ac..caf6917 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -25,21 +25,18 @@ use windows::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; use windows::Win32::UI::Input::KeyboardAndMouse::{ GetCapture, ReleaseCapture, SetCapture, TME_LEAVE, TRACKMOUSEEVENT, TrackMouseEvent, }; -use windows::Win32::UI::Shell::{ - DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass, -}; use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_STYLE, GWLP_ID, GWLP_USERDATA, - GWLP_WNDPROC, GetClientRect, GetParent, GetScrollInfo, GetWindowLongPtrW, GetWindowRect, HMENU, - HWND_TOP, IDC_ARROW, IsWindow, IsWindowVisible, KillTimer, LoadCursorW, MA_NOACTIVATE, - RegisterClassExW, SB_ENDSCROLL, SB_HORZ, SB_PAGEDOWN, SB_PAGEUP, SB_VERT, SCROLLINFO, SIF_ALL, - SM_CXVSCROLL, SM_CYHSCROLL, SWP_FRAMECHANGED, SWP_HIDEWINDOW, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, SendMessageW, SetTimer, SetWindowLongPtrW, - SetWindowPos, WM_CAPTURECHANGED, WM_ERASEBKGND, WM_HSCROLL, WM_KEYDOWN, WM_LBUTTONDBLCLK, - WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEACTIVATE, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, - WM_NCCALCSIZE, WM_NCDESTROY, WM_PAINT, WM_SETREDRAW, WM_SIZE, WM_TIMER, WM_VSCROLL, - WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_CHILD, WS_CLIPSIBLINGS, WS_EX_NOACTIVATE, WS_HSCROLL, - WS_VSCROLL, + CallWindowProcW, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_STYLE, GWLP_ID, + GWLP_USERDATA, GWLP_WNDPROC, GetClientRect, GetParent, GetScrollInfo, GetWindowLongPtrW, + GetWindowRect, HMENU, HWND_TOP, IDC_ARROW, IsWindow, IsWindowVisible, KillTimer, LoadCursorW, + MA_NOACTIVATE, RegisterClassExW, SB_ENDSCROLL, SB_HORZ, SB_PAGEDOWN, SB_PAGEUP, SB_VERT, + SCROLLINFO, SIF_ALL, SM_CXVSCROLL, SM_CYHSCROLL, SWP_FRAMECHANGED, SWP_HIDEWINDOW, + SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, SendMessageW, SetTimer, + SetWindowLongPtrW, SetWindowPos, WM_CAPTURECHANGED, WM_ERASEBKGND, WM_HSCROLL, WM_KEYDOWN, + WM_LBUTTONDBLCLK, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEACTIVATE, WM_MOUSEHWHEEL, WM_MOUSEMOVE, + WM_MOUSEWHEEL, WM_NCCALCSIZE, WM_NCDESTROY, WM_PAINT, WM_SETREDRAW, WM_SIZE, WM_TIMER, + WM_VSCROLL, WM_WINDOWPOSCHANGED, WNDCLASSEXW, WNDPROC, WS_CHILD, WS_CLIPSIBLINGS, + WS_EX_NOACTIVATE, WS_HSCROLL, WS_VSCROLL, }; use windows::core::{PCWSTR, w}; @@ -47,7 +44,6 @@ use super::theme::ThemeResources; use crate::ShellError; use crate::scroll::{ScrollRange, Thumb, page_direction, position_at, thumb}; -const SUBCLASS_ID: usize = 0x4c52_5342; const CLASS_NAME: PCWSTR = w!("LeanRowsScrollBar"); const VERTICAL_ID: isize = 1; const HORIZONTAL_ID: isize = 2; @@ -118,6 +114,8 @@ struct Overlay { owner: HWND, enabled: bool, syncing: bool, + /// The list's own window procedure, which gets every message first. + original: WNDPROC, bars: [Bar; 2], } @@ -188,40 +186,53 @@ pub(super) fn install(list: HWND, owner: HWND, enabled: bool) -> Result<(), Shel owner, enabled, syncing: false, + original: None, bars: [ Bar::new(vertical, Axis::Vertical), Bar::new(horizontal, Axis::Horizontal), ], })); - // SAFETY: The overlay outlives both bars and the list's subclass: the - // list's WM_NCDESTROY destroys the bars, then frees it. Each bar clears - // its own handle from it if it goes first. + // The list's window procedure is swapped for `list_proc`, which passes + // every message on to the list's own. Common controls' SetWindowSubclass + // would do the same through a dispatcher that cost a 10M-row Page Down + // about 1 ms. + // SAFETY: The overlay outlives both bars and the list's procedure swap: + // the list's WM_NCDESTROY destroys the bars, then frees it. Each bar + // clears its own handle from it if it goes first. The list's user data + // slot is not used by the list itself or by any other code here. unsafe { SetWindowLongPtrW(vertical, GWLP_USERDATA, overlay as isize); SetWindowLongPtrW(horizontal, GWLP_USERDATA, overlay as isize); let style = GetWindowLongPtrW(list, GWL_STYLE); SetWindowLongPtrW(list, GWL_STYLE, style | style_bit(WS_CLIPSIBLINGS)); - if !SetWindowSubclass(list, Some(list_proc), SUBCLASS_ID, overlay as usize).as_bool() { + SetWindowLongPtrW(list, GWLP_USERDATA, overlay as isize); + let original = SetWindowLongPtrW( + list, + GWLP_WNDPROC, + (list_proc as *const ()).addr().cast_signed(), + ); + if original == 0 { + SetWindowLongPtrW(list, GWLP_USERDATA, 0); let _ = DestroyWindow(vertical); let _ = DestroyWindow(horizontal); drop(Box::from_raw(overlay)); - return Err(ShellError::new("the list's scroll bar subclass failed")); + return Err(ShellError::new( + "the list's window procedure could not be replaced", + )); } + (*overlay).original = std::mem::transmute::(original); } refit(list); Ok(()) } fn overlay_for(list: HWND) -> *mut Overlay { - let mut data = 0_usize; - // SAFETY: The query only reads the subclass table of a live list. - let found = - unsafe { GetWindowSubclass(list, Some(list_proc), SUBCLASS_ID, Some(&raw mut data)) }; - if found.as_bool() { - std::ptr::with_exposed_provenance_mut::(data) - } else { - std::ptr::null_mut() + if !is_direct_procedure(list) { + return std::ptr::null_mut(); } + // SAFETY: A list whose procedure is `list_proc` holds its overlay in its + // user data slot (see `install`). + unsafe { GetWindowLongPtrW(list, GWLP_USERDATA) as *mut Overlay } } /// Turns the overlay on or off (off in high contrast) and repaints the bars @@ -309,19 +320,23 @@ unsafe extern "system" fn list_proc( message: u32, wparam: WPARAM, lparam: LPARAM, - subclass_id: usize, - reference_data: usize, ) -> LRESULT { - let pointer = std::ptr::with_exposed_provenance_mut::(reference_data); + // SAFETY: `install` put the overlay in the slot before this procedure. + let pointer = unsafe { GetWindowLongPtrW(list, GWLP_USERDATA) } as *mut Overlay; + // SAFETY: As above; the overlay lives until WM_NCDESTROY below. + let original = unsafe { pointer.as_ref() }.and_then(|overlay| overlay.original); + // SAFETY: The list's own procedure handles the message as it would + // without the overlay. + let list_own = || unsafe { CallWindowProcW(original, list, message, wparam, lparam) }; match message { WM_NCCALCSIZE => { // SAFETY: lparam carries the RECT or NCCALCSIZE_PARAMS of this // message; both start with the proposed window rectangle. let rect = lparam.0 as *mut RECT; let proposed = unsafe { rect.as_ref() }.copied(); - // SAFETY: The list's own frame calculation runs first. - let result = unsafe { DefSubclassProc(list, message, wparam, lparam) }; - // SAFETY: The overlay outlives the subclass. + // The list's own frame calculation runs first. + let result = list_own(); + // SAFETY: The overlay outlives the procedure swap. let enabled = unsafe { pointer.as_ref() }.is_some_and(|overlay| overlay.enabled); if enabled && let (Some(proposed), Some(client)) = (proposed, unsafe { rect.as_mut() }) { @@ -330,10 +345,17 @@ unsafe extern "system" fn list_proc( result } WM_NCDESTROY => { - // SAFETY: This removes the exact pair being invoked; the overlay - // is freed after its bars, and the list still gets the message. + // SAFETY: The list's procedure goes back first, the overlay is + // freed after its bars, and the list still gets the message. unsafe { - let _ = RemoveWindowSubclass(list, Some(list_proc), subclass_id); + if let Some(original) = original { + SetWindowLongPtrW( + list, + GWLP_WNDPROC, + (original as *const ()).addr().cast_signed(), + ); + } + SetWindowLongPtrW(list, GWLP_USERDATA, 0); if let Some(overlay) = pointer.as_mut() { for bar in &mut overlay.bars { if !bar.window.0.is_null() && IsWindow(Some(bar.window)).as_bool() { @@ -344,12 +366,12 @@ unsafe extern "system" fn list_proc( } drop(Box::from_raw(pointer)); } - DefSubclassProc(list, message, wparam, lparam) } + list_own() } _ => { - // SAFETY: Everything else stays with the list's own procedure. - let result = unsafe { DefSubclassProc(list, message, wparam, lparam) }; + // Everything else stays with the list's own procedure. + let result = list_own(); if is_sync_message(message) { // SAFETY: As above; `sync` guards against re-entry. if let Some(overlay) = unsafe { pointer.as_mut() } { From 3a91e5f19041918191929bcfc0512592daeda472 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:25:34 -0600 Subject: [PATCH 09/10] Leave the rows' window procedure in place on WM_NCDESTROY No message follows WM_NCDESTROY, so putting the list's procedure and user data back did nothing. --- crates/leanrows-win32/src/native/scrollbar.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index caf6917..b56b4ce 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -345,17 +345,10 @@ unsafe extern "system" fn list_proc( result } WM_NCDESTROY => { - // SAFETY: The list's procedure goes back first, the overlay is - // freed after its bars, and the list still gets the message. + // No message follows WM_NCDESTROY, so the procedure is not put back. + // SAFETY: The overlay is freed after its bars, and the list still + // gets the message through the procedure read above. unsafe { - if let Some(original) = original { - SetWindowLongPtrW( - list, - GWLP_WNDPROC, - (original as *const ()).addr().cast_signed(), - ); - } - SetWindowLongPtrW(list, GWLP_USERDATA, 0); if let Some(overlay) = pointer.as_mut() { for bar in &mut overlay.bars { if !bar.window.0.is_null() && IsWindow(Some(bar.window)).as_bool() { From 5b64bc9af5b67cc58bb10d7b6e1aa9078da51493 Mon Sep 17 00:00:00 2001 From: Abdulfatah Bahbouh <60443686+abooodbah@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:25:56 -0600 Subject: [PATCH 10/10] Cover the image list and style messages in the bar update test --- crates/leanrows-win32/src/native/scrollbar.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/leanrows-win32/src/native/scrollbar.rs b/crates/leanrows-win32/src/native/scrollbar.rs index b56b4ce..14724ff 100644 --- a/crates/leanrows-win32/src/native/scrollbar.rs +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -874,7 +874,8 @@ mod tests { use windows::Win32::UI::Controls::{ LVM_DELETEALLITEMS, LVM_DELETECOLUMN, LVM_ENSUREVISIBLE, LVM_GETCOUNTPERPAGE, LVM_GETHEADER, LVM_GETITEMCOUNT, LVM_GETITEMRECT, LVM_GETTOPINDEX, LVM_INSERTCOLUMNW, - LVM_SCROLL, LVM_SETCOLUMNWIDTH, LVM_SETITEMCOUNT, + LVM_SCROLL, LVM_SETCOLUMNWIDTH, LVM_SETEXTENDEDLISTVIEWSTYLE, LVM_SETIMAGELIST, + LVM_SETITEMCOUNT, }; /// While a 10M-row file pages down, the list is asked for its header and @@ -904,6 +905,8 @@ mod tests { LVM_INSERTCOLUMNW, LVM_DELETECOLUMN, LVM_DELETEALLITEMS, + LVM_SETIMAGELIST, + LVM_SETEXTENDEDLISTVIEWSTYLE, ] { assert!( is_sync_message(change),