From 738163749ab80cb1c418051069b2a4eebcd22acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Polykanine?= Date: Tue, 8 Sep 2026 14:54:26 +0200 Subject: [PATCH] Draw the drop indicator the control refuses to SetInsertionMark and ClearInsertionMark are back, and this time they draw something. They were removed in 1.0.0 because the control's own LVM_SETINSERTMARK is refused in report view - it returns FALSE and stores nothing - so the methods had never done anything for as long as they had existed. Their first consumer was calling them exactly as intended and getting no indicator at all. Removing them was right; leaving the gap was not. Dragging a row to reorder it with no line showing where it will land is a poor experience, and the library had already committed to the scenario: it ships ItemDrag, registers the list window as an OLE drop target, and documents GetItemBounds as the thing a drop indicator is positioned against. This belongs here rather than in an application because an application cannot do it. The list is a native child window that paints itself and covers the container, so a consumer never receives a paint event over it. Only the code that already answers the control's draw notifications can put anything on top of it. So the mark is an overlay painted at the post-paint stage, in SystemColors.Highlight so it follows the theme including high contrast, thickness scaled by DPI, with end caps so it reads as a line between rows rather than an underline belonging to one. Verified by rendering the window and counting pixels, because a drawing feature deserves nothing less: before=0, with the mark set=384, after clearing=0, at the row edge. PrintWindow rather than a screen capture, as a build agent has no interactive desktop and captures black there. Package validation is also switched on against the 1.0.0 baseline now that one exists. This change is purely additive and passes it; sealing a type or removing a member does not, which was checked both ways. Claude-Session: https://claude.ai/code/session_01TJ8i7jHmkUjVCjVccLp6Hf --- CHANGELOG.md | 23 ++++- README.md | 5 +- .../ListViewInterop.cs | 6 ++ .../NativeListView.cs | 99 +++++++++++++++++-- .../Oire.WinForms.NativeControls.csproj | 3 +- .../NativeListViewBehaviorTests.cs | 98 ++++++++++++++++++ 6 files changed, 224 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ca268..f7d5e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ in a major one. ## [Unreleased] +## [1.1.0] - 2026-09-08 + +### Added + +- `NativeListView.SetInsertionMark` and `ClearInsertionMark` are back, and this time they draw. + Removed in 1.0.0 because the control's own `LVM_SETINSERTMARK` is refused in report view - it + returns FALSE and stores nothing - they are now an overlay painted in the control's post-paint + stage, in `SystemColors.Highlight` so the line follows the theme including high contrast, and + scaled by DPI. + + This belongs in the library rather than in an application because an application cannot do it: + the list is a native child window that paints itself and covers the control, so a consumer + never receives a paint event over it. Only the code already answering the control's draw + notifications can put anything on top. + +### Changed + +- Package validation is enabled against the 1.0.0 baseline, so a breaking change to the public + surface now fails the build rather than reaching nuget.org. + ## [1.0.0] - 2026-09-08 First stable release. The public API is settled; see the versioning note above. @@ -175,7 +195,8 @@ First release. `GridPattern.GetItem(row, column)` returns unusable elements, which breaks cell navigation on JAWS, NVDA and Narrator alike. -[Unreleased]: https://github.com/Oire/winforms-native-controls/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/Oire/winforms-native-controls/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/Oire/winforms-native-controls/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/Oire/winforms-native-controls/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/Oire/winforms-native-controls/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/Oire/winforms-native-controls/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 137e7eb..054fddc 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,9 @@ carry covers what a list-driven application actually uses: column's `Alignment` and `SortOrder` arrow, both settable at any time. Left alone, the colors follow the system theme: light, dark and high contrast, and a switch between them while the application is running. -* **Hit testing and layout** — `GetItemAt`, `GetItemBounds` (which is what a drop indicator is - positioned against), and `BeginUpdate` / `EndUpdate` for bulk changes. +* **Hit testing and layout** — `GetItemAt`, `GetItemBounds`, `SetInsertionMark` / + `ClearInsertionMark` for a drop indicator during a reorder, and `BeginUpdate` / `EndUpdate` + for bulk changes. * **Events** — `SelectedIndexChanged`, `ColumnClick`, `ItemActivate`, `ItemDrag`, and the ordinary `DragEnter` / `DragOver` / `DragLeave` / `DragDrop` for drops on the list itself. diff --git a/src/Oire.WinForms.NativeControls/ListViewInterop.cs b/src/Oire.WinForms.NativeControls/ListViewInterop.cs index 154dd7a..ce05cef 100644 --- a/src/Oire.WinForms.NativeControls/ListViewInterop.cs +++ b/src/Oire.WinForms.NativeControls/ListViewInterop.cs @@ -192,6 +192,12 @@ internal struct INITCOMMONCONTROLSEX { internal const int CDRF_NEWFONT = 0x00000002; internal const int CDRF_NOTIFYITEMDRAW = 0x00000020; + /// Draw stage after the control has finished painting everything else. + internal const uint CDDS_POSTPAINT = 0x00000002; + + /// Asks for the post-paint callback, which is where an overlay belongs. + internal const int CDRF_NOTIFYPOSTPAINT = 0x00000010; + // --- Structs ------------------------------------------------------------------------- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] diff --git a/src/Oire.WinForms.NativeControls/NativeListView.cs b/src/Oire.WinForms.NativeControls/NativeListView.cs index ad4e7f5..183fa7f 100644 --- a/src/Oire.WinForms.NativeControls/NativeListView.cs +++ b/src/Oire.WinForms.NativeControls/NativeListView.cs @@ -59,6 +59,8 @@ public class NativeListView: Control { // assigning it as a reset, and Control.ResetBackColor is exactly that assignment. private Color? _backColor; private Color? _foreColor; + private int _insertionIndex = -1; + private bool _insertionAfter; private Size? _defaultSize; /// Rows a list asks room for before anything has told it how big to be. @@ -277,6 +279,46 @@ public void EndUpdate() { Invalidate(true); } + /// + /// Draws a drop indicator across the edge of a row: the line that tells a dragging user + /// where the thing they are holding is about to land. + /// + /// + /// The row to mark against, or -1 for no mark, which is what + /// passes. + /// + /// True to draw below the row, false to draw above it. + /// + /// + /// Drawn here rather than left to the application, because an application has no way to do + /// it. The list is a native child window that paints itself and covers this control, so a + /// consumer never receives a paint event over it; only the code that already answers the + /// control's draw notifications can put anything on top. + /// + /// + /// Not the control's own LVM_SETINSERTMARK, which is refused outright in report view + /// - it returns FALSE and stores nothing. This is an overlay drawn at the post-paint stage, + /// in so it follows the theme, including high contrast, + /// and scaled by DPI so it stays visible on a dense display. + /// + /// + public void SetInsertionMark(int index, bool after) { + var previous = _insertionIndex; + if (previous == index && _insertionAfter == after) { + return; + } + + _insertionIndex = index; + _insertionAfter = after; + + // Both the old line and the new one need repainting, and they are rarely the same row. + InvalidateRow(previous); + InvalidateRow(index); + } + + /// Removes the drop indicator. + public void ClearInsertionMark() => SetInsertionMark(-1, after: false); + /// Deselects every row. public void ClearSelection() { if (_listHandle == IntPtr.Zero) { @@ -1051,13 +1093,25 @@ private bool HandleCustomDraw(IntPtr lParam, out IntPtr result) { var draw = Marshal.PtrToStructure(lParam); switch (draw.Nmcd.DrawStage) { - case ListViewInterop.CDDS_PREPAINT: - // Ask for per-row callbacks only when some row has something to say. Otherwise - // every row of every repaint would cross into managed code to answer "nothing". - result = _items.Exists(item => item.ForeColor is not null) - ? ListViewInterop.CDRF_NOTIFYITEMDRAW - : ListViewInterop.CDRF_DODEFAULT; + case ListViewInterop.CDDS_PREPAINT: { + // Ask for callbacks only when there is something to answer with. Otherwise + // every row of every repaint would cross into managed code to say "nothing". + var flags = ListViewInterop.CDRF_DODEFAULT; + + if (_items.Exists(item => item.ForeColor is not null)) { + flags |= ListViewInterop.CDRF_NOTIFYITEMDRAW; + } + + if (HasInsertionMark) { + flags |= ListViewInterop.CDRF_NOTIFYPOSTPAINT; + } + result = flags; + return true; + } + + case ListViewInterop.CDDS_POSTPAINT: + DrawInsertionMark(draw.Nmcd.Hdc); return true; case ListViewInterop.CDDS_ITEMPREPAINT: { @@ -1079,6 +1133,39 @@ private bool HandleCustomDraw(IntPtr lParam, out IntPtr result) { } } + /// Whether a drop indicator is set and still points at a row that exists. + private bool HasInsertionMark => _insertionIndex >= 0 && _insertionIndex < _items.Count; + + /// + /// Draws the drop indicator across the row edge, with the end caps that make it read as a + /// line between rows rather than an underline belonging to one of them. + /// + private void DrawInsertionMark(IntPtr hdc) { + if (!HasInsertionMark || hdc == IntPtr.Zero) { + return; + } + + var bounds = GetItemBounds(_insertionIndex); + if (bounds.IsEmpty) { + return; + } + + var y = _insertionAfter ? bounds.Bottom : bounds.Top; + + // Scale with the display, and keep the line inside the control so a mark on the last + // row is not painted half outside it. + var thickness = Math.Max(2, (int)Math.Round(2 * (DeviceDpi / 96.0))); + var cap = thickness * 2; + y = Math.Clamp(y, thickness, Math.Max(thickness, ClientSize.Height - thickness)); + + using var graphics = Graphics.FromHdc(hdc); + using var brush = new SolidBrush(SystemColors.Highlight); + + graphics.FillRectangle(brush, bounds.Left, y - (thickness / 2), bounds.Width, thickness); + graphics.FillRectangle(brush, bounds.Left, y - cap, thickness, cap * 2); + graphics.FillRectangle(brush, bounds.Right - thickness, y - cap, thickness, cap * 2); + } + /// COLORREF is 0x00BBGGRR, the reverse of the usual order. private static uint ToColorRef(Color color) => (uint)(color.R | (color.G << 8) | (color.B << 16)); diff --git a/src/Oire.WinForms.NativeControls/Oire.WinForms.NativeControls.csproj b/src/Oire.WinForms.NativeControls/Oire.WinForms.NativeControls.csproj index d055516..a999c2b 100644 --- a/src/Oire.WinForms.NativeControls/Oire.WinForms.NativeControls.csproj +++ b/src/Oire.WinForms.NativeControls/Oire.WinForms.NativeControls.csproj @@ -78,7 +78,8 @@ 1.0.0 Thereafter bump the baseline to the newest published version whenever a deliberate breaking change ships in a new major. --> - false + true + 1.0.0 diff --git a/tests/Oire.WinForms.NativeControls.Tests/NativeListViewBehaviorTests.cs b/tests/Oire.WinForms.NativeControls.Tests/NativeListViewBehaviorTests.cs index 8ae74d8..dc1da7f 100644 --- a/tests/Oire.WinForms.NativeControls.Tests/NativeListViewBehaviorTests.cs +++ b/tests/Oire.WinForms.NativeControls.Tests/NativeListViewBehaviorTests.cs @@ -407,6 +407,104 @@ public void AllowDrop_ToggledOnAndOff_DoesNotThrow() { [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int SendMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + /// + /// The drop indicator is drawn as an overlay in the control's own post-paint stage, so the + /// only honest test is to render the window and look at the pixels. + /// + /// + /// Rendered with PrintWindow rather than captured from the screen: a build agent has + /// no interactive desktop, and a screen capture there returns black. + /// + [Fact] + public void InsertionMark_WhenSet_IsDrawnAtTheRowEdge() { + StaRunner.Run(() => { + using var form = new Form { ClientSize = new Size(420, 260) }; + using var list = Build(); + list.Dock = DockStyle.Fill; + form.Controls.Add(list); + form.Show(); + try { + var row = list.GetItemBounds(1); + row.IsEmpty.Should().BeFalse(); + + HighlightPixelsNear(list, row.Bottom).Should().Be(0, "nothing is marked yet"); + + list.SetInsertionMark(1, after: true); + + HighlightPixelsNear(list, row.Bottom) + .Should().BeGreaterThan(0, "the mark must be drawn at the row edge"); + + list.ClearInsertionMark(); + + HighlightPixelsNear(list, row.Bottom).Should().Be(0, "clearing must erase it"); + } finally { + form.Close(); + } + }); + } + + /// An index no row answers to must draw nothing rather than throw or smear. + [Fact] + public void InsertionMark_WithAnOutOfRangeIndex_DrawsNothing() { + StaRunner.Run(() => { + using var form = new Form { ClientSize = new Size(420, 260) }; + using var list = Build(); + list.Dock = DockStyle.Fill; + form.Controls.Add(list); + form.Show(); + try { + var act = () => list.SetInsertionMark(999, after: false); + act.Should().NotThrow(); + + var row = list.GetItemBounds(0); + HighlightPixelsNear(list, row.Top).Should().Be(0); + } finally { + form.Close(); + } + }); + } + + private const uint PW_RENDERFULLCONTENT = 0x00000002; + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint flags); + + /// Pixels close to the highlight color within a few rows of . + private static int HighlightPixelsNear(NativeListView list, int y) { + using var bitmap = new Bitmap( + Math.Max(1, list.ClientSize.Width), Math.Max(1, list.ClientSize.Height)); + + using (var graphics = Graphics.FromImage(bitmap)) { + var hdc = graphics.GetHdc(); + try { + PrintWindow(list.ListHandle, hdc, PW_RENDERFULLCONTENT); + } finally { + graphics.ReleaseHdc(hdc); + } + } + + var want = SystemColors.Highlight; + var hits = 0; + for (var dy = -5; dy <= 5; dy++) { + var row = y + dy; + if (row < 0 || row >= bitmap.Height) { + continue; + } + + for (var x = 0; x < bitmap.Width; x++) { + var pixel = bitmap.GetPixel(x, row); + if (Math.Abs(pixel.R - want.R) < 24 + && Math.Abs(pixel.G - want.G) < 24 + && Math.Abs(pixel.B - want.B) < 24) { + hits++; + } + } + } + + return hits; + } + private static NativeListView Build() { var list = new NativeListView { AccessibleName = "Notes" };