From 5f9d8f0b7df894e1e6f6b576f0b4e213301f3eef Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 11 Mar 2026 13:07:36 -0400 Subject: [PATCH 1/5] LT-22452 - fix mouse scrolling on date field in lex edit --- .serena/project.yml | 9 + Docs/mouse-wheel-analysis.md | 390 ++++++++++++++++++ .../Controls/DetailControls/DataTree.cs | 69 ++++ .../DataTreeScrollTests.cs | 77 ++++ 4 files changed, 545 insertions(+) create mode 100644 Docs/mouse-wheel-analysis.md create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs diff --git a/.serena/project.yml b/.serena/project.yml index 63d7787207..6babe78007 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -136,3 +136,12 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/Docs/mouse-wheel-analysis.md b/Docs/mouse-wheel-analysis.md new file mode 100644 index 0000000000..8e2156fea2 --- /dev/null +++ b/Docs/mouse-wheel-analysis.md @@ -0,0 +1,390 @@ +# Mouse Wheel Forwarding Analysis: ButtonLauncher → DataTree + +## Problem Statement + +When the user hovers the mouse cursor over a `ButtonLauncher` control inside the DataTree +(Lexicon Editor detail pane) and scrolls the mouse wheel, the DataTree does not scroll. +Scrolling works normally when the cursor is over other areas of the DataTree. + +--- + +## 1. Control Hierarchy + +``` +Form (ContainerControl) +└── SplitContainer / Panels + └── RecordEditView + └── DataTree : UserControl → ContainerControl → ScrollableControl + ├── Slice (various types) + │ └── ButtonLauncher : UserControl → ContainerControl → ScrollableControl + │ ├── m_panel : Panel (HWND) + │ │ └── m_btnLauncher : Button (HWND) + │ └── m_mainControl : RootSiteControl (HWND) ← most common + │ └── IVwRootBox (COM, renders into RootSiteControl's HWND) + ├── Slice (non-ButtonLauncher types) + │ └── various controls + └── (scrollbar managed by ScrollableControl base) +``` + +### Inheritance Chains + +| Class | Chain | +|-------|-------| +| **DataTree** | `UserControl → ContainerControl → ScrollableControl → Control` | +| **ButtonLauncher** | `UserControl → ContainerControl → ScrollableControl → Control` | +| **RootSiteControl** | `RootSite → SimpleRootSite → UserControl → ContainerControl → ScrollableControl → Control` | + +### ButtonLauncher Subclasses and Their MainControl Types + +| Subclass | MainControl | Type | +|----------|------------|------| +| AtomicReferenceLauncher | AtomicReferenceView | RootSiteControl (COM-based) | +| VectorReferenceLauncher | VectorReferenceView | RootSiteControl (COM-based) | +| PossibilityAtomicReferenceLauncher | AtomicReferenceView | RootSiteControl (COM-based) | +| MSADlgLauncher | MSADlglauncherView | RootSiteControl (COM-based) | +| PhonologicalFeatureListDlgLauncher | PhonologicalFeatureListDlgLauncherView | RootSiteControl (COM-based) | +| MsaInflectionFeatureListDlgLauncher | MsaInflectionFeatureListDlgLauncherView | RootSiteControl (COM-based) | +| RuleFormulaControl | PatternView | RootSiteControl (COM-based) | +| AudioVisualLauncher | AudioVisualView | RootSiteControl (COM-based) | +| GenDateLauncher | TextBox | Standard WinForms | +| GhostReferenceVectorLauncher | (none) | No MainControl | +| GhostLexRefLauncher | (none) | No MainControl | + +**8 out of 11 subclasses** use RootSiteControl-based views with COM rendering. + +--- + +## 2. Win32 WM_MOUSEWHEEL Message Routing + +### Standard Windows Behavior + +1. **WM_MOUSEWHEEL (0x020A)** is sent to the **focused window** (HWND with keyboard focus), + NOT the window under the mouse cursor. +2. **Windows 10+ "Scroll inactive windows"** setting routes WM_MOUSEWHEEL to the + top-level window under the cursor, but WinForms still routes internally via focus. +3. **DefWindowProc** bubbles unhandled WM_MOUSEWHEEL to the parent HWND. + +### WinForms ContainerControl Focus Routing + +`ContainerControl.WmMouseWheel` (called from `ContainerControl.WndProc`) adds another +routing layer: + +1. When a ContainerControl receives WM_MOUSEWHEEL, `WmMouseWheel` is called +2. `WmMouseWheel` finds the **ActiveControl** (focused child) +3. If the ActiveControl is another **ContainerControl**, it calls its `WmMouseWheel` recursively +4. Otherwise, it sends WM_MOUSEWHEEL to the ActiveControl via `SendMessage` +5. If nobody handles it, calls `DefWndProc` which bubbles to the parent + +**Key consequence**: The Form → DataTree → ButtonLauncher → MainControl chain always routes +WM_MOUSEWHEEL to the innermost focused control, never to a sibling or unrelated control. + +--- + +## 3. Message Flow Chain (When Cursor Is Over RootSiteControl MainControl) + +``` +WM_MOUSEWHEEL arrives at: Form HWND + ↓ Form.WndProc → ContainerControl.WmMouseWheel + ↓ Routes to ActiveControl (ultimately the focused control in DataTree) + ↓ +If ActiveControl chain leads to a ButtonLauncher child: + ↓ +RootSiteControl (MainControl) HWND receives WM_MOUSEWHEEL + ↓ SimpleRootSite.WndProc → MessageSequencer → OriginalWndProc + ↓ OriginalWndProc: no WM_MOUSEWHEEL handler → falls through to base.WndProc + ↓ UserControl.WndProc → ContainerControl.WndProc + ↓ ContainerControl.WmMouseWheel: no focused child → base.WndProc + ↓ ScrollableControl.WndProc → calls Control.WmMouseWheel + ↓ Control.WmMouseWheel → OnMouseWheel(HandledMouseEventArgs) + ↓ +RootSite.OnMouseWheel: + ↓ if m_group != null && this != scrollingController → redirect + ↓ else → base.OnMouseWheel(e) + ↓ +ScrollableControl.OnMouseWheel(e): + ↓ if (VScroll) → scroll → set Handled = true ← **GATE #1** + ↓ base.OnMouseWheel(e) → Control.OnMouseWheel(e) + ↓ Raises MouseWheel event + ↓ +ButtonLauncher.HandleForwardMouseWheel fires (registered on m_mainControl) + ↓ FindMouseWheelTarget() → DataTree + ↓ InvokeOnMouseWheel(dataTree, e) + ↓ +[reflection] s_onMouseWheelMethod.Invoke(dataTree, {e}) + → ScrollableControl.OnMouseWheel(e) on DataTree (virtual dispatch) + ↓ if (VScroll) → scroll → set Handled = true ← **GATE #2** + ↓ base.OnMouseWheel(e) → raises MouseWheel event +``` + +--- + +## 4. All Possible Failure Points + +### FP-1: WM_MOUSEWHEEL Never Reaches ButtonLauncher or Its Children + +**Scenario**: Focus is on a control OUTSIDE the ButtonLauncher (e.g., a non-ButtonLauncher +slice in the DataTree). WM_MOUSEWHEEL goes to that other control, never reaching our code. + +**Likelihood**: **LOW** in the specific test scenario (user clicks IN a ButtonLauncher field, +then scrolls). But in general usage, focus can be anywhere. + +**Impact**: No scroll at all — message never enters our forwarding code. + +### FP-2: ContainerControl.WmMouseWheel Re-routes Message + +**Scenario**: An intermediate ContainerControl (Form, SplitContainer, DataTree itself) +intercepts WM_MOUSEWHEEL and routes it to a focused child that is NOT inside a ButtonLauncher. + +**Likelihood**: **MEDIUM** — depends on focus state. If focus was last set to a control +inside a ButtonLauncher, the routing should deliver the message there. + +### FP-3: SimpleRootSite MessageSequencer Delays or Drops Message + +**Scenario**: SimpleRootSite.WndProc passes WM_MOUSEWHEEL through `m_messageSequencer.SequenceWndProc`. +The sequencer might delay, reorder, or drop the message. + +**Likelihood**: **LOW** — the message sequencer is designed the prevent re-entrant WndProc +calls, not to drop messages. + +### FP-4: RootSite.OnMouseWheel Redirects to ScrollingController + +**Scenario**: `RootSite.OnMouseWheel` checks `m_group` and if set, redirects the mouse wheel +to the scrolling controller. If the scrolling controller consumes the event without raising +the MouseWheel event to our handler, the forwarding fails. + +**Likelihood**: **LOW** for ButtonLauncher children, which are standalone field views not +typically in a scroll group. But worth verifying at runtime. + +### FP-5: ScrollableControl.OnMouseWheel VScroll Gate (MainControl) ★ + +**Scenario**: `ScrollableControl.OnMouseWheel` on the MainControl (RootSiteControl) checks +`if (VScroll)`. The MainControl is a small field control that does NOT have a vertical scrollbar. +`VScroll` is `false`. Therefore, `Handled` is NOT set to `true`. + +**BUT**: This is NOT a fatal failure. ScrollableControl.OnMouseWheel still calls +`base.OnMouseWheel(e)` even when VScroll is false, which raises the MouseWheel event. +Our HandleForwardMouseWheel handler fires regardless. + +**Impact**: None on forwarding — the event still reaches our handler. + +### FP-6: ScrollableControl.OnMouseWheel VScroll Gate (DataTree) ★★★ + +**Scenario**: When our forwarding code calls `ScrollableControl.OnMouseWheel` on the DataTree +(via reflection), it checks `if (VScroll)`. If DataTree's `VScroll` is `false`, no scrolling occurs. + +**Analysis**: +- DataTree sets `AutoScrollMinSize = new Size(0, yTop)` in `OnLayout` (line 3283) +- In .NET Framework, setting `AutoScrollMinSize` to a non-zero value automatically sets + `AutoScroll = true` (the setter includes `AutoScroll = true`) +- When `AutoScroll = true` and content exceeds viewport, `AdjustFormScrollbars` sets + `VScroll = true` +- The vertical scrollbar IS visible on the DataTree + +**Likelihood**: **LOW** if layout has completed. VScroll should be true since the scrollbar +is visible. + +### FP-7: Reflection MethodInfo.Invoke Dispatch ★★ + +**Scenario**: `typeof(Control).GetMethod("OnMouseWheel")` gets the `MethodInfo` for +`Control.OnMouseWheel`. When `Invoke` is called on a DataTree instance, virtual dispatch +should resolve to `ScrollableControl.OnMouseWheel` (the most-derived override). + +**Analysis**: `MethodInfo.Invoke` DOES perform virtual dispatch for virtual methods. +This is standard .NET behavior. + +**Likelihood**: **VERY LOW** — this is well-documented behavior. + +### FP-8: DataTree OnLayout Resets Scroll Position ★★ + +**Scenario**: After `SetDisplayRectLocation` changes the scroll position, WinForms triggers +a layout cycle. DataTree.OnLayout (line 3254) saves/restores `AutoScrollPosition`: + +```csharp +Point aspOld = AutoScrollPosition; +base.OnLayout(levent); +if (AutoScrollPosition != aspOld) + AutoScrollPosition = new Point(-aspOld.X, -aspOld.Y); +``` + +The layout code saves the position AFTER the scroll change, calls base.OnLayout, and +restores it if base.OnLayout changed it. This should preserve our scroll position change. + +**Likelihood**: **LOW** — the save/restore pattern preserves the current position. + +### FP-9: HandleForwardMouseWheel Event Not Wired ★★ + +**Scenario**: `RegisterWheelForwarding(m_mainControl)` subscribes to `m_mainControl.MouseWheel`. +But if the MainControl is set and replaced, or if the control handle is recreated, the event +subscription might be lost. + +**Analysis**: `RegisterWheelForwarding` is called in the `MainControl` setter: +```csharp +set { + Debug.Assert(m_mainControl == null); // only set once + m_mainControl = value; + m_mainControl.TabIndex = 0; + RegisterWheelForwarding(m_mainControl); +} +``` + +The `Debug.Assert(m_mainControl == null)` confirms it's only set once. So the subscription +should persist. + +**Likelihood**: **LOW** — single-assignment pattern ensures subscription is stable. + +### FP-10: ButtonLauncher.WndProc Never Receives WM_MOUSEWHEEL ★★★ + +**Scenario**: WM_MOUSEWHEEL is sent to the focused control, which is a CHILD of +ButtonLauncher (e.g., the RootSiteControl/MainControl). The message goes directly to the +child's HWND, never passing through ButtonLauncher's WndProc. + +In this case, only `HandleForwardMouseWheel` (the MouseWheel event handler on the child) +can catch it. ButtonLauncher.WndProc is bypassed entirely. + +**Impact**: Our WndProc-based interception ONLY works when ButtonLauncher itself has focus. +For child-focused scenarios, we rely entirely on the MouseWheel event subscription. + +**Likelihood**: **HIGH** — in practice, focus is almost always on a child control (the +RootSiteControl/TextBox), not on the ButtonLauncher UserControl itself. + +--- + +## 5. Root Cause Analysis + +The most likely root cause is a combination of failure points, depending on the specific +scenario: + +### Scenario A: Focus is on a child control inside ButtonLauncher + +1. WM_MOUSEWHEEL → child control (RootSiteControl) +2. RootSiteControl processes the message through its WndProc chain +3. Eventually OnMouseWheel is called → raises MouseWheel event +4. HandleForwardMouseWheel fires → calls InvokeOnMouseWheel on DataTree +5. ScrollableControl.OnMouseWheel on DataTree checks `VScroll` +6. **If VScroll is true**: scrolling should work +7. **If VScroll is false**: no scrolling (but this is unlikely given visible scrollbar) + +### Scenario B: Focus is NOT inside any ButtonLauncher + +1. WM_MOUSEWHEEL → focused control elsewhere in DataTree +2. ButtonLauncher's code never runs +3. No forwarding occurs + +### Most Probable Root Cause: **OnMouseWheel via reflection works correctly, +but the actual scrolling mechanism in ScrollableControl may behave differently +than expected in .NET Framework 4.8** + +The .NET Framework 4.8 `ScrollableControl.OnMouseWheel` may have subtle differences +from the .NET Core source we analyzed. Additionally, the reflection-based approach +calls `OnMouseWheel` outside the normal WndProc processing context, which means: + +- No Win32 message context (MSG structure, message pump state) +- `SetDisplayRectLocation` may trigger layout/paint events synchronously +- The scroll change may be undone by subsequent processing + +### Recommended Fix: **IMessageFilter + Direct AutoScrollPosition manipulation** + +The fix uses two layers: + +**Layer 1 (primary): `IMessageFilter`** — Intercepts WM_MOUSEWHEEL at the application +message pump level, before any ContainerControl routing or focus-based dispatch. +Checks if the cursor is over any registered ButtonLauncher using screen coordinates. +If so, directly scrolls the parent DataTree via `AutoScrollPosition`. + +```csharp +private sealed class WheelRedirector : IMessageFilter +{ + public bool PreFilterMessage(ref Message m) + { + if (m.Msg != WM_MOUSEWHEEL) return false; + Point cursor = Cursor.Position; + foreach (var launcher in m_launchers) + { + Rectangle bounds = launcher.RectangleToScreen(launcher.ClientRectangle); + if (bounds.Contains(cursor)) + { + var target = launcher.FindMouseWheelTarget(); // DataTree + int delta = (short)((long)m.WParam >> 16); + launcher.ScrollTarget(target, delta); + return true; // consumed + } + } + return false; + } +} +``` + +**Layer 2 (fallback): WndProc + MouseWheel event handlers** — Kept for robustness +and test support. The WndProc override catches any WM_MOUSEWHEEL sent directly to +the ButtonLauncher HWND. The MouseWheel event handlers catch events propagated +through the child control's OnMouseWheel chain. + +**AutoScrollPosition manipulation** (both layers): +```csharp +int currentY = -dataTree.AutoScrollPosition.Y; +int maxScroll = Math.Max(0, dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); +int newY = Math.Max(0, Math.Min(currentY - delta, maxScroll)); +dataTree.AutoScrollPosition = new Point(0, newY); +``` + +--- + +## 6. .NET Framework ScrollableControl.OnMouseWheel Source (Reference) + +From the dotnet/winforms open-source (.NET Core, should be similar to .NET FW 4.8): + +```csharp +protected override void OnMouseWheel(MouseEventArgs e) +{ + if (VScroll) + { + Rectangle client = ClientRectangle; + int pos = -_displayRect.Y; + int maxPos = -(client.Height - _displayRect.Height); + pos = Math.Max(pos - e.Delta, 0); + pos = Math.Min(pos, maxPos); + SetDisplayRectLocation(_displayRect.X, -pos); + SyncScrollbars(AutoScroll); + if (e is HandledMouseEventArgs args) + args.Handled = true; + } + else if (HScroll) + { + // ... similar horizontal scroll logic + } + + // Always call base, which raises MouseWheel event + base.OnMouseWheel(e); +} +``` + +Key observations: +- **`VScroll` is a protected property** set by `AdjustFormScrollbars`/`SetVisibleScrollbars` + during layout. It reflects whether the vertical scrollbar is currently visible. +- **`SetDisplayRectLocation`** physically moves child controls and scrolls the window. +- **`SyncScrollbars`** updates scrollbar thumb position and range. +- **`e.Delta`** is used directly as pixel offset (no `SystemInformation.MouseWheelScrollLines` + multiplication in this implementation). + +--- + +## 7. DataTree Scroll Management + +DataTree does NOT set `AutoScroll = true` explicitly. Instead, it manages scrolling through: + +1. **`AutoScrollMinSize`** — Set in `OnLayout` (line 3283) to the total height of all slices. + In .NET Framework, setting `AutoScrollMinSize` automatically sets `AutoScroll = true`. + +2. **`AutoScrollPosition`** — Read throughout layout methods. Manually saved and restored + in `OnLayout` to prevent `base.OnLayout` from resetting it. + +3. **`OnLayout` override** (lines 3254-3310) — Runs up to 3 iterations: + - Saves `AutoScrollPosition` + - Calls `base.OnLayout` (which may change scroll position) + - Restores position if changed + - Calls `HandleLayout1` to position slices + - Sets `AutoScrollMinSize` based on total slice height + +4. **`WndProc` override** (line 4695) — Minimal: saves Y, delegates to base, logs if changed. + +5. **No `OnMouseWheel` override** — Relies entirely on `ScrollableControl.OnMouseWheel`. diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 65adc8f8c6..1356dfa926 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -477,6 +477,7 @@ public DataTree() m_autoCustomFieldNodesDocument = new XmlDocument(); m_autoCustomFieldNodesDocRoot = m_autoCustomFieldNodesDocument.CreateElement("root"); m_autoCustomFieldNodesDocument.AppendChild(m_autoCustomFieldNodesDocRoot); + s_wheelRedirector.Register(this); } /// @@ -1243,6 +1244,8 @@ protected override void Dispose(bool disposing) if (IsDisposed) return; + s_wheelRedirector.Unregister(this); + // m_sda COM object block removed due to crash in Finializer thread LT-6124 if (disposing) @@ -4531,6 +4534,72 @@ private bool EquivalentKeys(object[] newKey, object[] oldKey, bool fCheckInts) } return true; } + + private const int WM_MOUSEWHEEL = 0x020A; + private static readonly WheelRedirector s_wheelRedirector = new WheelRedirector(); + + /// + /// Application-level message filter that intercepts WM_MOUSEWHEEL messages + /// and scrolls the DataTree when the cursor is over its client area. + /// Some child controls (e.g. RichTextBox in DateSlice) consume WM_MOUSEWHEEL + /// without propagating it, preventing the DataTree from scrolling. + /// Intercepting at the message pump level ensures consistent scroll behavior + /// regardless of which child control has focus. + /// + private sealed class WheelRedirector : IMessageFilter + { + private readonly List m_dataTrees = new List(); + private bool m_installed; + + public void Register(DataTree dataTree) + { + m_dataTrees.Add(dataTree); + if (!m_installed) + { + Application.AddMessageFilter(this); + m_installed = true; + } + } + + public void Unregister(DataTree dataTree) + { + m_dataTrees.Remove(dataTree); + if (m_dataTrees.Count == 0 && m_installed) + { + Application.RemoveMessageFilter(this); + m_installed = false; + } + } + + public bool PreFilterMessage(ref Message m) + { + if (m.Msg != WM_MOUSEWHEEL) + return false; + + Point cursor = Cursor.Position; + for (int i = 0; i < m_dataTrees.Count; i++) + { + var dataTree = m_dataTrees[i]; + if (!dataTree.IsHandleCreated || dataTree.IsDisposed) + continue; + + Rectangle bounds = dataTree.RectangleToScreen(dataTree.ClientRectangle); + if (!bounds.Contains(cursor)) + continue; + + int delta = (short)((long)m.WParam >> 16); + int currentY = -dataTree.AutoScrollPosition.Y; + int maxScroll = Math.Max(0, + dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); + int newY = Math.Max(0, Math.Min(currentY - delta, maxScroll)); + if (newY != currentY) + dataTree.AutoScrollPosition = new Point(0, newY); + return true; + } + + return false; + } + } } class DummyObjectSlice : Slice diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs new file mode 100644 index 0000000000..fbeddafd1d --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Drawing; +using System.Windows.Forms; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Tests that the DataTree WheelRedirector (IMessageFilter) correctly + /// scrolls the DataTree when WM_MOUSEWHEEL would otherwise be consumed + /// by a child control (e.g. the RichTextBox in DateSlice). + /// + [TestFixture] + public class DataTreeScrollTests + { + /// + /// DataTree subclass that skips base OnPaint to avoid NullReferenceException + /// from HandlePaintLinesBetweenSlices when test slices have no LCM objects. + /// + private sealed class TestDataTree : DataTree + { + protected override void OnPaint(PaintEventArgs e) + { + } + } + + /// + /// Verifies that programmatic AutoScrollPosition manipulation works + /// on a DataTree with enough content to be scrollable, confirming + /// the scroll calculation used by the WheelRedirector is correct. + /// + [Test] + public void DataTree_ScrollPositionManipulation_ScrollsCorrectly() + { + using (var parent = new Form()) + using (var dataTree = new TestDataTree()) + { + parent.Size = new Size(400, 200); + dataTree.Dock = DockStyle.Fill; + parent.Controls.Add(dataTree); + + for (int i = 0; i < 12; i++) + { + var slice = new Slice(new Panel { Dock = DockStyle.Fill }) + { + Visible = true, + Size = new Size(360, 50), + Location = new Point(0, i * 50) + }; + dataTree.Controls.Add(slice); + slice.Install(dataTree); + } + + parent.Show(); + Application.DoEvents(); + + // Simulate how WheelRedirector calculates scroll: delta -120 = scroll down + int delta = -120; + int currentY = -dataTree.AutoScrollPosition.Y; + int maxScroll = System.Math.Max(0, + dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); + int newY = System.Math.Max(0, System.Math.Min(currentY - delta, maxScroll)); + + Assert.That(maxScroll, Is.GreaterThan(0), + "DataTree content must exceed viewport for scrolling to be possible"); + + dataTree.AutoScrollPosition = new Point(0, newY); + + Assert.That(-dataTree.AutoScrollPosition.Y, Is.GreaterThan(0), + "DataTree should have scrolled down"); + } + } + } +} From ba633e1f503dc25e837582558d44267d3e868f23 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 11 Mar 2026 13:08:41 -0400 Subject: [PATCH 2/5] We don't need this file --- Docs/mouse-wheel-analysis.md | 390 ----------------------------------- 1 file changed, 390 deletions(-) delete mode 100644 Docs/mouse-wheel-analysis.md diff --git a/Docs/mouse-wheel-analysis.md b/Docs/mouse-wheel-analysis.md deleted file mode 100644 index 8e2156fea2..0000000000 --- a/Docs/mouse-wheel-analysis.md +++ /dev/null @@ -1,390 +0,0 @@ -# Mouse Wheel Forwarding Analysis: ButtonLauncher → DataTree - -## Problem Statement - -When the user hovers the mouse cursor over a `ButtonLauncher` control inside the DataTree -(Lexicon Editor detail pane) and scrolls the mouse wheel, the DataTree does not scroll. -Scrolling works normally when the cursor is over other areas of the DataTree. - ---- - -## 1. Control Hierarchy - -``` -Form (ContainerControl) -└── SplitContainer / Panels - └── RecordEditView - └── DataTree : UserControl → ContainerControl → ScrollableControl - ├── Slice (various types) - │ └── ButtonLauncher : UserControl → ContainerControl → ScrollableControl - │ ├── m_panel : Panel (HWND) - │ │ └── m_btnLauncher : Button (HWND) - │ └── m_mainControl : RootSiteControl (HWND) ← most common - │ └── IVwRootBox (COM, renders into RootSiteControl's HWND) - ├── Slice (non-ButtonLauncher types) - │ └── various controls - └── (scrollbar managed by ScrollableControl base) -``` - -### Inheritance Chains - -| Class | Chain | -|-------|-------| -| **DataTree** | `UserControl → ContainerControl → ScrollableControl → Control` | -| **ButtonLauncher** | `UserControl → ContainerControl → ScrollableControl → Control` | -| **RootSiteControl** | `RootSite → SimpleRootSite → UserControl → ContainerControl → ScrollableControl → Control` | - -### ButtonLauncher Subclasses and Their MainControl Types - -| Subclass | MainControl | Type | -|----------|------------|------| -| AtomicReferenceLauncher | AtomicReferenceView | RootSiteControl (COM-based) | -| VectorReferenceLauncher | VectorReferenceView | RootSiteControl (COM-based) | -| PossibilityAtomicReferenceLauncher | AtomicReferenceView | RootSiteControl (COM-based) | -| MSADlgLauncher | MSADlglauncherView | RootSiteControl (COM-based) | -| PhonologicalFeatureListDlgLauncher | PhonologicalFeatureListDlgLauncherView | RootSiteControl (COM-based) | -| MsaInflectionFeatureListDlgLauncher | MsaInflectionFeatureListDlgLauncherView | RootSiteControl (COM-based) | -| RuleFormulaControl | PatternView | RootSiteControl (COM-based) | -| AudioVisualLauncher | AudioVisualView | RootSiteControl (COM-based) | -| GenDateLauncher | TextBox | Standard WinForms | -| GhostReferenceVectorLauncher | (none) | No MainControl | -| GhostLexRefLauncher | (none) | No MainControl | - -**8 out of 11 subclasses** use RootSiteControl-based views with COM rendering. - ---- - -## 2. Win32 WM_MOUSEWHEEL Message Routing - -### Standard Windows Behavior - -1. **WM_MOUSEWHEEL (0x020A)** is sent to the **focused window** (HWND with keyboard focus), - NOT the window under the mouse cursor. -2. **Windows 10+ "Scroll inactive windows"** setting routes WM_MOUSEWHEEL to the - top-level window under the cursor, but WinForms still routes internally via focus. -3. **DefWindowProc** bubbles unhandled WM_MOUSEWHEEL to the parent HWND. - -### WinForms ContainerControl Focus Routing - -`ContainerControl.WmMouseWheel` (called from `ContainerControl.WndProc`) adds another -routing layer: - -1. When a ContainerControl receives WM_MOUSEWHEEL, `WmMouseWheel` is called -2. `WmMouseWheel` finds the **ActiveControl** (focused child) -3. If the ActiveControl is another **ContainerControl**, it calls its `WmMouseWheel` recursively -4. Otherwise, it sends WM_MOUSEWHEEL to the ActiveControl via `SendMessage` -5. If nobody handles it, calls `DefWndProc` which bubbles to the parent - -**Key consequence**: The Form → DataTree → ButtonLauncher → MainControl chain always routes -WM_MOUSEWHEEL to the innermost focused control, never to a sibling or unrelated control. - ---- - -## 3. Message Flow Chain (When Cursor Is Over RootSiteControl MainControl) - -``` -WM_MOUSEWHEEL arrives at: Form HWND - ↓ Form.WndProc → ContainerControl.WmMouseWheel - ↓ Routes to ActiveControl (ultimately the focused control in DataTree) - ↓ -If ActiveControl chain leads to a ButtonLauncher child: - ↓ -RootSiteControl (MainControl) HWND receives WM_MOUSEWHEEL - ↓ SimpleRootSite.WndProc → MessageSequencer → OriginalWndProc - ↓ OriginalWndProc: no WM_MOUSEWHEEL handler → falls through to base.WndProc - ↓ UserControl.WndProc → ContainerControl.WndProc - ↓ ContainerControl.WmMouseWheel: no focused child → base.WndProc - ↓ ScrollableControl.WndProc → calls Control.WmMouseWheel - ↓ Control.WmMouseWheel → OnMouseWheel(HandledMouseEventArgs) - ↓ -RootSite.OnMouseWheel: - ↓ if m_group != null && this != scrollingController → redirect - ↓ else → base.OnMouseWheel(e) - ↓ -ScrollableControl.OnMouseWheel(e): - ↓ if (VScroll) → scroll → set Handled = true ← **GATE #1** - ↓ base.OnMouseWheel(e) → Control.OnMouseWheel(e) - ↓ Raises MouseWheel event - ↓ -ButtonLauncher.HandleForwardMouseWheel fires (registered on m_mainControl) - ↓ FindMouseWheelTarget() → DataTree - ↓ InvokeOnMouseWheel(dataTree, e) - ↓ -[reflection] s_onMouseWheelMethod.Invoke(dataTree, {e}) - → ScrollableControl.OnMouseWheel(e) on DataTree (virtual dispatch) - ↓ if (VScroll) → scroll → set Handled = true ← **GATE #2** - ↓ base.OnMouseWheel(e) → raises MouseWheel event -``` - ---- - -## 4. All Possible Failure Points - -### FP-1: WM_MOUSEWHEEL Never Reaches ButtonLauncher or Its Children - -**Scenario**: Focus is on a control OUTSIDE the ButtonLauncher (e.g., a non-ButtonLauncher -slice in the DataTree). WM_MOUSEWHEEL goes to that other control, never reaching our code. - -**Likelihood**: **LOW** in the specific test scenario (user clicks IN a ButtonLauncher field, -then scrolls). But in general usage, focus can be anywhere. - -**Impact**: No scroll at all — message never enters our forwarding code. - -### FP-2: ContainerControl.WmMouseWheel Re-routes Message - -**Scenario**: An intermediate ContainerControl (Form, SplitContainer, DataTree itself) -intercepts WM_MOUSEWHEEL and routes it to a focused child that is NOT inside a ButtonLauncher. - -**Likelihood**: **MEDIUM** — depends on focus state. If focus was last set to a control -inside a ButtonLauncher, the routing should deliver the message there. - -### FP-3: SimpleRootSite MessageSequencer Delays or Drops Message - -**Scenario**: SimpleRootSite.WndProc passes WM_MOUSEWHEEL through `m_messageSequencer.SequenceWndProc`. -The sequencer might delay, reorder, or drop the message. - -**Likelihood**: **LOW** — the message sequencer is designed the prevent re-entrant WndProc -calls, not to drop messages. - -### FP-4: RootSite.OnMouseWheel Redirects to ScrollingController - -**Scenario**: `RootSite.OnMouseWheel` checks `m_group` and if set, redirects the mouse wheel -to the scrolling controller. If the scrolling controller consumes the event without raising -the MouseWheel event to our handler, the forwarding fails. - -**Likelihood**: **LOW** for ButtonLauncher children, which are standalone field views not -typically in a scroll group. But worth verifying at runtime. - -### FP-5: ScrollableControl.OnMouseWheel VScroll Gate (MainControl) ★ - -**Scenario**: `ScrollableControl.OnMouseWheel` on the MainControl (RootSiteControl) checks -`if (VScroll)`. The MainControl is a small field control that does NOT have a vertical scrollbar. -`VScroll` is `false`. Therefore, `Handled` is NOT set to `true`. - -**BUT**: This is NOT a fatal failure. ScrollableControl.OnMouseWheel still calls -`base.OnMouseWheel(e)` even when VScroll is false, which raises the MouseWheel event. -Our HandleForwardMouseWheel handler fires regardless. - -**Impact**: None on forwarding — the event still reaches our handler. - -### FP-6: ScrollableControl.OnMouseWheel VScroll Gate (DataTree) ★★★ - -**Scenario**: When our forwarding code calls `ScrollableControl.OnMouseWheel` on the DataTree -(via reflection), it checks `if (VScroll)`. If DataTree's `VScroll` is `false`, no scrolling occurs. - -**Analysis**: -- DataTree sets `AutoScrollMinSize = new Size(0, yTop)` in `OnLayout` (line 3283) -- In .NET Framework, setting `AutoScrollMinSize` to a non-zero value automatically sets - `AutoScroll = true` (the setter includes `AutoScroll = true`) -- When `AutoScroll = true` and content exceeds viewport, `AdjustFormScrollbars` sets - `VScroll = true` -- The vertical scrollbar IS visible on the DataTree - -**Likelihood**: **LOW** if layout has completed. VScroll should be true since the scrollbar -is visible. - -### FP-7: Reflection MethodInfo.Invoke Dispatch ★★ - -**Scenario**: `typeof(Control).GetMethod("OnMouseWheel")` gets the `MethodInfo` for -`Control.OnMouseWheel`. When `Invoke` is called on a DataTree instance, virtual dispatch -should resolve to `ScrollableControl.OnMouseWheel` (the most-derived override). - -**Analysis**: `MethodInfo.Invoke` DOES perform virtual dispatch for virtual methods. -This is standard .NET behavior. - -**Likelihood**: **VERY LOW** — this is well-documented behavior. - -### FP-8: DataTree OnLayout Resets Scroll Position ★★ - -**Scenario**: After `SetDisplayRectLocation` changes the scroll position, WinForms triggers -a layout cycle. DataTree.OnLayout (line 3254) saves/restores `AutoScrollPosition`: - -```csharp -Point aspOld = AutoScrollPosition; -base.OnLayout(levent); -if (AutoScrollPosition != aspOld) - AutoScrollPosition = new Point(-aspOld.X, -aspOld.Y); -``` - -The layout code saves the position AFTER the scroll change, calls base.OnLayout, and -restores it if base.OnLayout changed it. This should preserve our scroll position change. - -**Likelihood**: **LOW** — the save/restore pattern preserves the current position. - -### FP-9: HandleForwardMouseWheel Event Not Wired ★★ - -**Scenario**: `RegisterWheelForwarding(m_mainControl)` subscribes to `m_mainControl.MouseWheel`. -But if the MainControl is set and replaced, or if the control handle is recreated, the event -subscription might be lost. - -**Analysis**: `RegisterWheelForwarding` is called in the `MainControl` setter: -```csharp -set { - Debug.Assert(m_mainControl == null); // only set once - m_mainControl = value; - m_mainControl.TabIndex = 0; - RegisterWheelForwarding(m_mainControl); -} -``` - -The `Debug.Assert(m_mainControl == null)` confirms it's only set once. So the subscription -should persist. - -**Likelihood**: **LOW** — single-assignment pattern ensures subscription is stable. - -### FP-10: ButtonLauncher.WndProc Never Receives WM_MOUSEWHEEL ★★★ - -**Scenario**: WM_MOUSEWHEEL is sent to the focused control, which is a CHILD of -ButtonLauncher (e.g., the RootSiteControl/MainControl). The message goes directly to the -child's HWND, never passing through ButtonLauncher's WndProc. - -In this case, only `HandleForwardMouseWheel` (the MouseWheel event handler on the child) -can catch it. ButtonLauncher.WndProc is bypassed entirely. - -**Impact**: Our WndProc-based interception ONLY works when ButtonLauncher itself has focus. -For child-focused scenarios, we rely entirely on the MouseWheel event subscription. - -**Likelihood**: **HIGH** — in practice, focus is almost always on a child control (the -RootSiteControl/TextBox), not on the ButtonLauncher UserControl itself. - ---- - -## 5. Root Cause Analysis - -The most likely root cause is a combination of failure points, depending on the specific -scenario: - -### Scenario A: Focus is on a child control inside ButtonLauncher - -1. WM_MOUSEWHEEL → child control (RootSiteControl) -2. RootSiteControl processes the message through its WndProc chain -3. Eventually OnMouseWheel is called → raises MouseWheel event -4. HandleForwardMouseWheel fires → calls InvokeOnMouseWheel on DataTree -5. ScrollableControl.OnMouseWheel on DataTree checks `VScroll` -6. **If VScroll is true**: scrolling should work -7. **If VScroll is false**: no scrolling (but this is unlikely given visible scrollbar) - -### Scenario B: Focus is NOT inside any ButtonLauncher - -1. WM_MOUSEWHEEL → focused control elsewhere in DataTree -2. ButtonLauncher's code never runs -3. No forwarding occurs - -### Most Probable Root Cause: **OnMouseWheel via reflection works correctly, -but the actual scrolling mechanism in ScrollableControl may behave differently -than expected in .NET Framework 4.8** - -The .NET Framework 4.8 `ScrollableControl.OnMouseWheel` may have subtle differences -from the .NET Core source we analyzed. Additionally, the reflection-based approach -calls `OnMouseWheel` outside the normal WndProc processing context, which means: - -- No Win32 message context (MSG structure, message pump state) -- `SetDisplayRectLocation` may trigger layout/paint events synchronously -- The scroll change may be undone by subsequent processing - -### Recommended Fix: **IMessageFilter + Direct AutoScrollPosition manipulation** - -The fix uses two layers: - -**Layer 1 (primary): `IMessageFilter`** — Intercepts WM_MOUSEWHEEL at the application -message pump level, before any ContainerControl routing or focus-based dispatch. -Checks if the cursor is over any registered ButtonLauncher using screen coordinates. -If so, directly scrolls the parent DataTree via `AutoScrollPosition`. - -```csharp -private sealed class WheelRedirector : IMessageFilter -{ - public bool PreFilterMessage(ref Message m) - { - if (m.Msg != WM_MOUSEWHEEL) return false; - Point cursor = Cursor.Position; - foreach (var launcher in m_launchers) - { - Rectangle bounds = launcher.RectangleToScreen(launcher.ClientRectangle); - if (bounds.Contains(cursor)) - { - var target = launcher.FindMouseWheelTarget(); // DataTree - int delta = (short)((long)m.WParam >> 16); - launcher.ScrollTarget(target, delta); - return true; // consumed - } - } - return false; - } -} -``` - -**Layer 2 (fallback): WndProc + MouseWheel event handlers** — Kept for robustness -and test support. The WndProc override catches any WM_MOUSEWHEEL sent directly to -the ButtonLauncher HWND. The MouseWheel event handlers catch events propagated -through the child control's OnMouseWheel chain. - -**AutoScrollPosition manipulation** (both layers): -```csharp -int currentY = -dataTree.AutoScrollPosition.Y; -int maxScroll = Math.Max(0, dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); -int newY = Math.Max(0, Math.Min(currentY - delta, maxScroll)); -dataTree.AutoScrollPosition = new Point(0, newY); -``` - ---- - -## 6. .NET Framework ScrollableControl.OnMouseWheel Source (Reference) - -From the dotnet/winforms open-source (.NET Core, should be similar to .NET FW 4.8): - -```csharp -protected override void OnMouseWheel(MouseEventArgs e) -{ - if (VScroll) - { - Rectangle client = ClientRectangle; - int pos = -_displayRect.Y; - int maxPos = -(client.Height - _displayRect.Height); - pos = Math.Max(pos - e.Delta, 0); - pos = Math.Min(pos, maxPos); - SetDisplayRectLocation(_displayRect.X, -pos); - SyncScrollbars(AutoScroll); - if (e is HandledMouseEventArgs args) - args.Handled = true; - } - else if (HScroll) - { - // ... similar horizontal scroll logic - } - - // Always call base, which raises MouseWheel event - base.OnMouseWheel(e); -} -``` - -Key observations: -- **`VScroll` is a protected property** set by `AdjustFormScrollbars`/`SetVisibleScrollbars` - during layout. It reflects whether the vertical scrollbar is currently visible. -- **`SetDisplayRectLocation`** physically moves child controls and scrolls the window. -- **`SyncScrollbars`** updates scrollbar thumb position and range. -- **`e.Delta`** is used directly as pixel offset (no `SystemInformation.MouseWheelScrollLines` - multiplication in this implementation). - ---- - -## 7. DataTree Scroll Management - -DataTree does NOT set `AutoScroll = true` explicitly. Instead, it manages scrolling through: - -1. **`AutoScrollMinSize`** — Set in `OnLayout` (line 3283) to the total height of all slices. - In .NET Framework, setting `AutoScrollMinSize` automatically sets `AutoScroll = true`. - -2. **`AutoScrollPosition`** — Read throughout layout methods. Manually saved and restored - in `OnLayout` to prevent `base.OnLayout` from resetting it. - -3. **`OnLayout` override** (lines 3254-3310) — Runs up to 3 iterations: - - Saves `AutoScrollPosition` - - Calls `base.OnLayout` (which may change scroll position) - - Restores position if changed - - Calls `HandleLayout1` to position slices - - Sets `AutoScrollMinSize` based on total slice height - -4. **`WndProc` override** (line 4695) — Minimal: saves Y, delegates to base, logs if changed. - -5. **No `OnMouseWheel` override** — Relies entirely on `ScrollableControl.OnMouseWheel`. From affc71c4933ea4fd854ae24352282742d850a61b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 12 Mar 2026 12:04:32 -0400 Subject: [PATCH 3/5] Remove test - no popup tests --- .../DataTreeScrollTests.cs | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs deleted file mode 100644 index fbeddafd1d..0000000000 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeScrollTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Drawing; -using System.Windows.Forms; -using NUnit.Framework; - -namespace SIL.FieldWorks.Common.Framework.DetailControls -{ - /// - /// Tests that the DataTree WheelRedirector (IMessageFilter) correctly - /// scrolls the DataTree when WM_MOUSEWHEEL would otherwise be consumed - /// by a child control (e.g. the RichTextBox in DateSlice). - /// - [TestFixture] - public class DataTreeScrollTests - { - /// - /// DataTree subclass that skips base OnPaint to avoid NullReferenceException - /// from HandlePaintLinesBetweenSlices when test slices have no LCM objects. - /// - private sealed class TestDataTree : DataTree - { - protected override void OnPaint(PaintEventArgs e) - { - } - } - - /// - /// Verifies that programmatic AutoScrollPosition manipulation works - /// on a DataTree with enough content to be scrollable, confirming - /// the scroll calculation used by the WheelRedirector is correct. - /// - [Test] - public void DataTree_ScrollPositionManipulation_ScrollsCorrectly() - { - using (var parent = new Form()) - using (var dataTree = new TestDataTree()) - { - parent.Size = new Size(400, 200); - dataTree.Dock = DockStyle.Fill; - parent.Controls.Add(dataTree); - - for (int i = 0; i < 12; i++) - { - var slice = new Slice(new Panel { Dock = DockStyle.Fill }) - { - Visible = true, - Size = new Size(360, 50), - Location = new Point(0, i * 50) - }; - dataTree.Controls.Add(slice); - slice.Install(dataTree); - } - - parent.Show(); - Application.DoEvents(); - - // Simulate how WheelRedirector calculates scroll: delta -120 = scroll down - int delta = -120; - int currentY = -dataTree.AutoScrollPosition.Y; - int maxScroll = System.Math.Max(0, - dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); - int newY = System.Math.Max(0, System.Math.Min(currentY - delta, maxScroll)); - - Assert.That(maxScroll, Is.GreaterThan(0), - "DataTree content must exceed viewport for scrolling to be possible"); - - dataTree.AutoScrollPosition = new Point(0, newY); - - Assert.That(-dataTree.AutoScrollPosition.Y, Is.GreaterThan(0), - "DataTree should have scrolled down"); - } - } - } -} From 1be4ea9422a01cfd48ded758c026889b443f0455 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 13 Mar 2026 10:44:31 -0400 Subject: [PATCH 4/5] Move under disposing --- Src/Common/Controls/DetailControls/DataTree.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 1356dfa926..4caa92e2f3 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -1244,12 +1244,13 @@ protected override void Dispose(bool disposing) if (IsDisposed) return; - s_wheelRedirector.Unregister(this); - // m_sda COM object block removed due to crash in Finializer thread LT-6124 if (disposing) { + + s_wheelRedirector.Unregister(this); + Subscriber.Unsubscribe(EventConstants.PostponePropChanged, PostponePropChanged); // Do this first, before setting m_fDisposing to true. From 7ac2ac0fa808cf6ef8521047514eab23d4696d6d Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 13 Mar 2026 14:19:20 -0400 Subject: [PATCH 5/5] Respond to AI reviewer comments --- .../Controls/DetailControls/DataTree.cs | 61 ++++++++++---- .../DetailControlsTests/DataTreeTests.cs | 81 +++++++++++++++++++ 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 4caa92e2f3..6e27d56c0d 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -4536,9 +4536,39 @@ private bool EquivalentKeys(object[] newKey, object[] oldKey, bool fCheckInts) return true; } - private const int WM_MOUSEWHEEL = 0x020A; private static readonly WheelRedirector s_wheelRedirector = new WheelRedirector(); + internal static int GetWheelScrollPixels(DataTree dataTree, int delta) + { + if (delta == 0) + return 0; + + int scrollLines = SystemInformation.MouseWheelScrollLines; + if (scrollLines == 0) + return 0; + + if (scrollLines == int.MaxValue) + return Math.Sign(delta) * dataTree.ClientRectangle.Height; + + double linesToScroll = (double)delta / SystemInformation.MouseWheelScrollDelta * scrollLines; + return (int)Math.Round(linesToScroll * dataTree.Font.Height, MidpointRounding.AwayFromZero); + } + + internal static bool TryGetWheelScrollPosition(DataTree dataTree, int delta, out int newY) + { + int currentY = -dataTree.AutoScrollPosition.Y; + int maxScroll = Math.Max(0, + dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); + int pixelDelta = GetWheelScrollPixels(dataTree, delta); + newY = Math.Max(0, Math.Min(currentY - pixelDelta, maxScroll)); + return newY != currentY; + } + + internal static bool CanRedirectWheelMessage(DataTree dataTree) + { + return dataTree.IsHandleCreated && !dataTree.IsDisposed && dataTree.Visible; + } + /// /// Application-level message filter that intercepts WM_MOUSEWHEEL messages /// and scrolls the DataTree when the cursor is over its client area. @@ -4549,12 +4579,13 @@ private bool EquivalentKeys(object[] newKey, object[] oldKey, bool fCheckInts) /// private sealed class WheelRedirector : IMessageFilter { - private readonly List m_dataTrees = new List(); + private readonly HashSet m_dataTrees = new HashSet(); private bool m_installed; public void Register(DataTree dataTree) { - m_dataTrees.Add(dataTree); + if (!m_dataTrees.Add(dataTree)) + return; if (!m_installed) { Application.AddMessageFilter(this); @@ -4564,7 +4595,9 @@ public void Register(DataTree dataTree) public void Unregister(DataTree dataTree) { - m_dataTrees.Remove(dataTree); + if (!m_dataTrees.Remove(dataTree)) + return; + if (m_dataTrees.Count == 0 && m_installed) { Application.RemoveMessageFilter(this); @@ -4574,14 +4607,13 @@ public void Unregister(DataTree dataTree) public bool PreFilterMessage(ref Message m) { - if (m.Msg != WM_MOUSEWHEEL) + if (m.Msg != (int)Win32.WinMsgs.WM_MOUSEWHEEL) return false; Point cursor = Cursor.Position; - for (int i = 0; i < m_dataTrees.Count; i++) + foreach (var dataTree in m_dataTrees) { - var dataTree = m_dataTrees[i]; - if (!dataTree.IsHandleCreated || dataTree.IsDisposed) + if (!CanRedirectWheelMessage(dataTree)) continue; Rectangle bounds = dataTree.RectangleToScreen(dataTree.ClientRectangle); @@ -4589,13 +4621,14 @@ public bool PreFilterMessage(ref Message m) continue; int delta = (short)((long)m.WParam >> 16); - int currentY = -dataTree.AutoScrollPosition.Y; - int maxScroll = Math.Max(0, - dataTree.AutoScrollMinSize.Height - dataTree.ClientRectangle.Height); - int newY = Math.Max(0, Math.Min(currentY - delta, maxScroll)); - if (newY != currentY) + int newY; + if (TryGetWheelScrollPosition(dataTree, delta, out newY)) + { dataTree.AutoScrollPosition = new Point(0, newY); - return true; + return true; + } + + return false; } return false; diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs index ac83210b6d..830b68fb10 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.IO; +using System.Reflection; using System.Windows.Forms; using System.Xml; using NUnit.Framework; @@ -275,6 +277,85 @@ public void GetGuidForJumpToTool_UsesRootObject_WhenNoCurrentSlice() } } + [Test] + public void GetWheelScrollPixels_UsesSystemWheelSettings() + { + m_dtree.Bounds = new Rectangle(0, 0, 200, 100); + + int delta = SystemInformation.MouseWheelScrollDelta; + int scrollLines = SystemInformation.MouseWheelScrollLines; + int expectedPixels; + if (scrollLines == 0) + { + expectedPixels = 0; + } + else if (scrollLines == int.MaxValue) + { + expectedPixels = m_dtree.ClientRectangle.Height; + } + else + { + expectedPixels = (int)Math.Round((double)scrollLines * m_dtree.Font.Height, + MidpointRounding.AwayFromZero); + } + + Assert.That(DataTree.GetWheelScrollPixels(m_dtree, delta), Is.EqualTo(expectedPixels)); + Assert.That(DataTree.GetWheelScrollPixels(m_dtree, -delta), Is.EqualTo(-expectedPixels)); + } + + [Test] + public void TryGetWheelScrollPosition_ReturnsFalse_WhenAlreadyAtTop() + { + m_dtree.Bounds = new Rectangle(0, 0, 200, 100); + m_dtree.AutoScrollMinSize = new Size(200, 1000); + m_dtree.AutoScrollPosition = new Point(0, 0); + + int newY; + bool handled = DataTree.TryGetWheelScrollPosition(m_dtree, + SystemInformation.MouseWheelScrollDelta, out newY); + + Assert.That(handled, Is.False); + Assert.That(newY, Is.EqualTo(0)); + } + + [Test] + public void CanRedirectWheelMessage_ReturnsFalse_WhenDataTreeHidden() + { + m_parent.Show(); + m_dtree.Show(); + Assert.That(m_dtree.IsHandleCreated, Is.True); + + m_dtree.Hide(); + + Assert.That(DataTree.CanRedirectWheelMessage(m_dtree), Is.False); + } + + [Test] + public void Register_RegisteredTwice_AddsOneEntryAndSingleUnregisterRemovesIt() + { + Type wheelRedirectorType = typeof(DataTree).GetNestedType("WheelRedirector", BindingFlags.NonPublic); + Assert.That(wheelRedirectorType, Is.Not.Null); + + object redirector = Activator.CreateInstance(wheelRedirectorType, true); + MethodInfo register = wheelRedirectorType.GetMethod("Register", BindingFlags.Public | BindingFlags.Instance); + MethodInfo unregister = wheelRedirectorType.GetMethod("Unregister", BindingFlags.Public | BindingFlags.Instance); + FieldInfo dataTreesField = wheelRedirectorType.GetField("m_dataTrees", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(register, Is.Not.Null); + Assert.That(unregister, Is.Not.Null); + Assert.That(dataTreesField, Is.Not.Null); + + register.Invoke(redirector, new object[] { m_dtree }); + register.Invoke(redirector, new object[] { m_dtree }); + + object registrations = dataTreesField.GetValue(redirector); + PropertyInfo countProperty = registrations.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.Instance); + Assert.That(countProperty, Is.Not.Null); + Assert.That((int)countProperty.GetValue(registrations, null), Is.EqualTo(1)); + + unregister.Invoke(redirector, new object[] { m_dtree }); + Assert.That((int)countProperty.GetValue(registrations, null), Is.EqualTo(0)); + } + /// [Test] public void OwnedObjects()