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/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 65adc8f8c6..6e27d56c0d 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); } /// @@ -1247,6 +1248,9 @@ protected override void Dispose(bool disposing) if (disposing) { + + s_wheelRedirector.Unregister(this); + Subscriber.Unsubscribe(EventConstants.PostponePropChanged, PostponePropChanged); // Do this first, before setting m_fDisposing to true. @@ -4531,6 +4535,105 @@ private bool EquivalentKeys(object[] newKey, object[] oldKey, bool fCheckInts) } return true; } + + 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. + /// 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 HashSet m_dataTrees = new HashSet(); + private bool m_installed; + + public void Register(DataTree dataTree) + { + if (!m_dataTrees.Add(dataTree)) + return; + if (!m_installed) + { + Application.AddMessageFilter(this); + m_installed = true; + } + } + + public void Unregister(DataTree dataTree) + { + if (!m_dataTrees.Remove(dataTree)) + return; + + if (m_dataTrees.Count == 0 && m_installed) + { + Application.RemoveMessageFilter(this); + m_installed = false; + } + } + + public bool PreFilterMessage(ref Message m) + { + if (m.Msg != (int)Win32.WinMsgs.WM_MOUSEWHEEL) + return false; + + Point cursor = Cursor.Position; + foreach (var dataTree in m_dataTrees) + { + if (!CanRedirectWheelMessage(dataTree)) + continue; + + Rectangle bounds = dataTree.RectangleToScreen(dataTree.ClientRectangle); + if (!bounds.Contains(cursor)) + continue; + + int delta = (short)((long)m.WParam >> 16); + int newY; + if (TryGetWheelScrollPosition(dataTree, delta, out newY)) + { + dataTree.AutoScrollPosition = new Point(0, newY); + return true; + } + + return false; + } + + return false; + } + } } class DummyObjectSlice : Slice 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()