diff --git a/crates/leanrows-win32/src/lib.rs b/crates/leanrows-win32/src/lib.rs index f8714dc..01b0260 100644 --- a/crates/leanrows-win32/src/lib.rs +++ b/crates/leanrows-win32/src/lib.rs @@ -16,6 +16,8 @@ mod native; #[cfg(not(windows))] mod native_stub; #[cfg_attr(not(windows), allow(dead_code))] +mod scroll; +#[cfg_attr(not(windows), allow(dead_code))] mod shortcuts; mod sort_coordinator; mod worker; diff --git a/crates/leanrows-win32/src/native.rs b/crates/leanrows-win32/src/native.rs index 91e6e08..c92e9c7 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)); @@ -1017,6 +1019,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 +1652,366 @@ 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", + )); + } + // 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")); + } + // 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); + 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); + 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); + // 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. +#[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}; + 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 check = |at: &str| -> Result<(), ShellError> { + // 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; + 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(); + 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; + 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", + 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) }; + // 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( + window, + WM_COMMAND, + Some(WPARAM(usize::from(settings::ID_SETTINGS_CLOSE))), + Some(LPARAM(0)), + ); + } + 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()?; @@ -3151,6 +3514,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); @@ -3193,7 +3557,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. @@ -3214,6 +3579,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> { @@ -3438,7 +3809,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 { @@ -4551,6 +4925,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); } @@ -4973,6 +5359,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. @@ -7069,6 +7456,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..14724ff --- /dev/null +++ b/crates/leanrows-win32/src/native/scrollbar.rs @@ -0,0 +1,917 @@ +//! 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_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::{ + GetCapture, ReleaseCapture, SetCapture, TME_LEAVE, TRACKMOUSEEVENT, TrackMouseEvent, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + 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}; + +use super::theme::ThemeResources; +use crate::ShellError; +use crate::scroll::{ScrollRange, Thumb, page_direction, position_at, thumb}; + +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, + /// The list's own window procedure, which gets every message first. + original: WNDPROC, + 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, + original: None, + bars: [ + Bar::new(vertical, Axis::Vertical), + Bar::new(horizontal, Axis::Horizontal), + ], + })); + // 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)); + 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 window procedure could not be replaced", + )); + } + (*overlay).original = std::mem::transmute::(original); + } + refit(list); + Ok(()) +} + +fn overlay_for(list: HWND) -> *mut Overlay { + 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 +/// 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)) +} + +/// 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. + let _ = unsafe { + SetWindowPos( + list, + None, + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + ) + }; +} + +/// 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, + WM_PAINT + | WM_WINDOWPOSCHANGED + | WM_SIZE + | WM_VSCROLL + | WM_HSCROLL + | WM_MOUSEWHEEL + | WM_MOUSEHWHEEL + | WM_KEYDOWN + | WM_SETREDRAW + | LVM_SETITEMCOUNT + | LVM_SCROLL + | LVM_ENSUREVISIBLE + | LVM_SETCOLUMNWIDTH + | LVM_INSERTCOLUMNW + | LVM_DELETECOLUMN + | LVM_DELETEALLITEMS + | LVM_SETIMAGELIST + | LVM_SETEXTENDEDLISTVIEWSTYLE + ) +} + +unsafe extern "system" fn list_proc( + list: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + // 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(); + // 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() }) + { + narrow_frame(list, proposed, client); + } + result + } + WM_NCDESTROY => { + // 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(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)); + } + } + list_own() + } + _ => { + // 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() } { + 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); + } +} + +#[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_SETEXTENDEDLISTVIEWSTYLE, LVM_SETIMAGELIST, + 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, + LVM_SETIMAGELIST, + LVM_SETEXTENDEDLISTVIEWSTYLE, + ] { + assert!( + is_sync_message(change), + "{change:#06x} did not move the bars" + ); + } + } +} 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 { 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); + } +}