Browse Source

Merge branch 'v2_2489_scroll-scrollbar-new' of github.com:BDisp/Terminal.Gui into BDisp-v2_2489_scroll-scrollbar-new

Tig 1 year ago
parent
commit
5cc3afb2b4

+ 11 - 13
Terminal.Gui/View/SuperViewChangedEventArgs.cs

@@ -1,28 +1,26 @@
 namespace Terminal.Gui;
 
 /// <summary>
-///     Args for events where the <see cref="View.SuperView"/> of a <see cref="View"/> is changed (e.g.
+///     EventArgs for events where the state of the <see cref="View.SuperView"/> of a <see cref="View"/> is changing (e.g.
 ///     <see cref="View.Removed"/> / <see cref="View.Added"/> events).
 /// </summary>
 public class SuperViewChangedEventArgs : EventArgs
 {
     /// <summary>Creates a new instance of the <see cref="SuperViewChangedEventArgs"/> class.</summary>
-    /// <param name="parent"></param>
-    /// <param name="child"></param>
-    public SuperViewChangedEventArgs (View parent, View child)
+    /// <param name="superView"></param>
+    /// <param name="subView"></param>
+    public SuperViewChangedEventArgs (View superView, View subView)
     {
-        Parent = parent;
-        Child = child;
+        SuperView = superView;
+        SubView = subView;
     }
 
-    // TODO: Child is the wrong name. It should be View.
-    /// <summary>The view that is having it's <see cref="View.SuperView"/> changed</summary>
-    public View Child { get; }
+    /// <summary>The SubView that is either being added or removed from <see cref="Parent"/>.</summary>
+    public View SubView { get; }
 
-    // TODO: Parent is the wrong name. It should be SuperView.
     /// <summary>
-    ///     The parent.  For <see cref="View.Removed"/> this is the old parent (new parent now being null).  For
-    ///     <see cref="View.Added"/> it is the new parent to whom view now belongs.
+    ///     The SuperView that is changing state. For <see cref="View.Removed"/> this is the SuperView <see cref="SubView"/> is being removed from. For
+    ///     <see cref="View.Added"/> it is the SuperView <see cref="SubView"/> is being added to.
     /// </summary>
-    public View Parent { get; }
+    public View SuperView { get; }
 }

+ 2 - 2
Terminal.Gui/View/ViewSubViews.cs

@@ -189,7 +189,7 @@ public partial class View
     /// <param name="e">Event where <see cref="ViewEventArgs.View"/> is the subview being added.</param>
     public virtual void OnAdded (SuperViewChangedEventArgs e)
     {
-        View view = e.Child;
+        View view = e.SubView;
         view.IsAdded = true;
         view.OnResizeNeeded ();
         view.Added?.Invoke (this, e);
@@ -199,7 +199,7 @@ public partial class View
     /// <param name="e">Event args describing the subview being removed.</param>
     public virtual void OnRemoved (SuperViewChangedEventArgs e)
     {
-        View view = e.Child;
+        View view = e.SubView;
         view.IsAdded = false;
         view.Removed?.Invoke (this, e);
     }

+ 424 - 0
Terminal.Gui/Views/Scroll.cs

@@ -0,0 +1,424 @@
+namespace Terminal.Gui;
+
+/// <summary>
+///     Provides a proportional control for scrolling through content. Used within a <see cref="ScrollBar"/>.
+/// </summary>
+public class Scroll : View
+{
+    /// <inheritdoc/>
+    public Scroll ()
+    {
+        WantContinuousButtonPressed = true;
+        CanFocus = false;
+        Orientation = Orientation.Vertical;
+        Width = Dim.Auto (DimAutoStyle.Content, 1);
+        Height = Dim.Auto (DimAutoStyle.Content, 1);
+
+        _slider = new ()
+        {
+            Id = "slider",
+            Width = Dim.Auto (DimAutoStyle.Content),
+            Height = Dim.Auto (DimAutoStyle.Content),
+            WantMousePositionReports = true
+        };
+        Add (_slider);
+
+        Added += Scroll_Added;
+        Removed += Scroll_Removed;
+        Initialized += Scroll_Initialized;
+        MouseEvent += Scroll_MouseEvent;
+        _slider.MouseEvent += Slider_MouseEvent;
+        _slider.MouseEnter += Slider_MouseEnter;
+        _slider.MouseLeave += Slider_MouseLeave;
+    }
+
+    private readonly View _slider;
+
+    private int _lastLocation = -1;
+
+    private bool _wasSliderMouse;
+
+    private Orientation _orientation;
+    /// <summary>
+    ///     Gets or sets if the Scroll is oriented vertically or horizontally.
+    /// </summary>
+    public Orientation Orientation
+    {
+        get => _orientation;
+        set
+        {
+            _orientation = value;
+            AdjustSlider();
+        }
+    }
+
+    private int _position;
+    /// <summary>
+    ///     Gets or sets the position of the start of the Scroll slider, relative to <see cref="Size"/>.
+    /// </summary>
+    public int Position
+    {
+        get => _position;
+        set
+        {
+            if (value == _position || value < 0)
+            {
+                return;
+            }
+
+            int barSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
+
+            if (value + barSize > Size)
+            {
+                return;
+            }
+
+            StateEventArgs<int> args = OnPositionChanging (_position, value);
+
+            if (args.Cancel)
+            {
+                return;
+            }
+
+            if (!_wasSliderMouse)
+            {
+                AdjustSlider ();
+            }
+
+            int oldPos = _position;
+            _position = value;
+            OnPositionChanged (oldPos);
+        }
+    }
+
+    /// <summary>Raised when the <see cref="Position"/> has changed.</summary>
+    public event EventHandler<StateEventArgs<int>> PositionChanged;
+
+    /// <summary>Raised when the <see cref="Position"/> is changing. Set <see cref="StateEventArgs{T}.Cancel"/> to <see langword="true"/> to prevent the position from being changed.</summary>
+    public event EventHandler<StateEventArgs<int>> PositionChanging;
+
+    /// <summary>Virtual method called when <see cref="Position"/> has changed. Fires <see cref="PositionChanged"/>.</summary>
+    protected virtual void OnPositionChanged (int oldPos) { PositionChanged?.Invoke (this, new (oldPos, Position)); }
+
+    /// <summary>Virtual method called when <see cref="Position"/> is changing. Fires <see cref="PositionChanging"/>, which is cancelable.</summary>
+    protected virtual StateEventArgs<int> OnPositionChanging (int oldPos, int newPos)
+    {
+        StateEventArgs<int> args = new (oldPos, newPos);
+        PositionChanging?.Invoke (this, args);
+
+        return args;
+    }
+
+    private int _size;
+    /// <summary>
+    ///     Gets or sets the size of the Scroll. This is the total size of the content that can be scrolled through.
+    /// </summary>
+    public int Size
+    {
+        get => _size;
+        set
+        {
+            int oldSize = _size;
+            _size = value;
+            OnSizeChanged (oldSize);
+            AdjustSlider ();
+        }
+    }
+
+    /// <summary>Raised when <see cref="Size"/> has changed.</summary>
+    public event EventHandler<StateEventArgs<int>> SizeChanged;
+
+    /// <summary>Virtual method called when <see cref="Size"/> has changed. Fires <see cref="SizeChanged"/>.</summary>
+    protected void OnSizeChanged (int oldSize) { SizeChanged?.Invoke (this, new (oldSize, Size)); }
+
+    private int GetPositionFromSliderLocation (int location)
+    {
+        if (GetContentSize ().Height == 0 || GetContentSize ().Width == 0)
+        {
+            return 0;
+        }
+
+        int scrollSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
+
+        // Ensure the Position is valid if the slider is at end
+        // We use Frame here instead of ContentSize because even if the slider has a margin or border, Frame indicates the actual size
+        if ((Orientation == Orientation.Vertical && location + _slider.Frame.Height == scrollSize)
+            || (Orientation == Orientation.Horizontal && location + _slider.Frame.Width == scrollSize))
+        {
+            return Size - scrollSize;
+        }
+
+        return Math.Min (location * Size / scrollSize, Size - scrollSize);
+    }
+
+    // QUESTION: This method is only called from one place. Should it be inlined? Or, should it be made internal and unit tests be provided?
+    private (int Location, int Dimension) GetSliderLocationDimensionFromPosition ()
+    {
+        if (GetContentSize ().Height == 0 || GetContentSize ().Width == 0)
+        {
+            return new (0, 0);
+        }
+
+        int scrollSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
+        int location;
+        int dimension;
+
+        if (Size > 0)
+        {
+            dimension = Math.Min (Math.Max (scrollSize * scrollSize / Size, 1), scrollSize);
+
+            // Ensure the Position is valid
+            if (Position > 0 && Position + scrollSize > Size)
+            {
+                Position = Size - scrollSize;
+            }
+
+            location = Math.Min (Position * scrollSize / Size, scrollSize - dimension);
+
+            if (Position == Size - scrollSize && location + dimension < scrollSize)
+            {
+                location = scrollSize - dimension;
+            }
+        }
+        else
+        {
+            location = 0;
+            dimension = scrollSize;
+        }
+
+        return new (location, dimension);
+    }
+
+    // TODO: This is unnecessary. If Scroll.Width/Height is Dim.Auto, the Superview will get resized automatically.
+    private void SuperView_LayoutComplete (object sender, LayoutEventArgs e)
+    {
+        if (!_wasSliderMouse)
+        {
+            AdjustSlider ();
+        }
+        else
+        {
+            _wasSliderMouse = false;
+        }
+    }
+
+
+
+    private void Scroll_Added (object sender, SuperViewChangedEventArgs e)
+    {
+        View parent = e.SuperView is Adornment adornment ? adornment.Parent : e.SuperView;
+
+        parent.LayoutComplete += SuperView_LayoutComplete;
+    }
+
+
+    /// <inheritdoc />
+    public override Attribute GetNormalColor ()
+    {
+        if (_savedColorScheme is null)
+        {
+            _slider.ColorScheme = new () { Normal = new (ColorScheme.HotNormal.Foreground, ColorScheme.HotNormal.Foreground) };
+        }
+        else
+        {
+            _slider.ColorScheme = new () { Normal = new (ColorScheme.Normal.Foreground, ColorScheme.Normal.Foreground) };
+        }
+
+        return base.GetNormalColor ();
+    }
+
+    private void Scroll_Initialized (object sender, EventArgs e)
+    {
+        AdjustSlider ();
+    }
+
+    // TODO: I think you should create a new `internal` view named "ScrollSlider" with an `Orientation` property. It should inherit from View and override GetNormalColor and the mouse events
+    // that can be moved within it's Superview, constrained to move only horizontally or vertically depending on Orientation.
+    // This will really simplify a lot of this.
+    private void Scroll_MouseEvent (object sender, MouseEventEventArgs e)
+    {
+        MouseEvent me = e.MouseEvent;
+        int location = Orientation == Orientation.Vertical ? me.Position.Y : me.Position.X;
+        int barSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
+
+        (int topLeft, int bottomRight) sliderPos = _orientation == Orientation.Vertical
+                                                       ? new (_slider.Frame.Y, _slider.Frame.Bottom - 1)
+                                                       : new (_slider.Frame.X, _slider.Frame.Right - 1);
+
+        if (me.Flags == MouseFlags.Button1Pressed && location < sliderPos.topLeft)
+        {
+            Position = Math.Max (Position - barSize, 0);
+        }
+        else if (me.Flags == MouseFlags.Button1Pressed && location > sliderPos.bottomRight)
+        {
+            Position = Math.Min (Position + barSize, Size - barSize);
+        }
+        else if ((me.Flags == MouseFlags.WheeledDown && Orientation == Orientation.Vertical)
+                 || (me.Flags == MouseFlags.WheeledRight && Orientation == Orientation.Horizontal))
+        {
+            Position = Math.Min (Position + 1, Size - barSize);
+        }
+        else if ((me.Flags == MouseFlags.WheeledUp && Orientation == Orientation.Vertical)
+                 || (me.Flags == MouseFlags.WheeledLeft && Orientation == Orientation.Horizontal))
+        {
+            Position = Math.Max (Position - 1, 0);
+        }
+        else if (me.Flags == MouseFlags.Button1Clicked)
+        {
+            if (_slider.Frame.Contains (me.Position))
+            {
+                Slider_MouseEnter (_slider, e);
+            }
+        }
+    }
+
+    private void Scroll_Removed (object sender, SuperViewChangedEventArgs e)
+    {
+        if (e.SuperView is { })
+        {
+            View parent = e.SuperView is Adornment adornment ? adornment.Parent : e.SuperView;
+
+            parent.LayoutComplete -= SuperView_LayoutComplete;
+        }
+    }
+
+    private void SetSliderText ()
+    {
+        TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom;
+
+        // QUESTION: Should these Glyphs be configurable via CM?
+        Text = string.Concat (
+                              Enumerable.Repeat (
+                                                 Glyphs.Stipple.ToString (),
+                                                 GetContentSize ().Width * GetContentSize ().Height));
+        _slider.TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom;
+
+        _slider.Text = string.Concat (
+                                      Enumerable.Repeat (
+                                                         Glyphs.ContinuousMeterSegment.ToString (),
+                                                         _slider.GetContentSize ().Width * _slider.GetContentSize ().Height));
+    }
+
+    private void AdjustSlider ()
+    {
+        if (!IsInitialized)
+        {
+            return;
+        }
+
+        (int Location, int Dimension) slider = GetSliderLocationDimensionFromPosition ();
+        _slider.X = Orientation == Orientation.Vertical ? 0 : slider.Location;
+        _slider.Y = Orientation == Orientation.Vertical ? slider.Location : 0;
+
+        _slider.SetContentSize (
+                                new (
+                                     Orientation == Orientation.Vertical ? GetContentSize ().Width : slider.Dimension,
+                                     Orientation == Orientation.Vertical ? slider.Dimension : GetContentSize ().Height
+                                    ));
+        SetSliderText ();
+    }
+
+    // TODO: Move this into "ScrollSlider" and override it there. Scroll can then subscribe to _slider.LayoutComplete and call AdjustSlider.
+    // QUESTION: I've been meaning to add a "View.FrameChanged" event (fired from LayoutComplete only if Frame has changed). Should we do that as part of this PR?
+    // QUESTION: Note I *did* add "View.ViewportChanged" in a previous PR.
+    private void Slider_MouseEvent (object sender, MouseEventEventArgs e)
+    {
+        MouseEvent me = e.MouseEvent;
+        int location = Orientation == Orientation.Vertical ? me.Position.Y : me.Position.X;
+        int offset = _lastLocation > -1 ? location - _lastLocation : 0;
+        int barSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
+
+        if (me.Flags == MouseFlags.Button1Pressed)
+        {
+            if (Application.MouseGrabView != sender as View)
+            {
+                Application.GrabMouse (sender as View);
+                _lastLocation = location;
+            }
+        }
+        else if (me.Flags == (MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition))
+        {
+            if (Orientation == Orientation.Vertical)
+            {
+                if (_slider.Frame.Y + offset >= 0 && _slider.Frame.Y + offset + _slider.Frame.Height <= barSize)
+                {
+                    _wasSliderMouse = true;
+                    _slider.Y = _slider.Frame.Y + offset;
+                    Position = GetPositionFromSliderLocation (_slider.Frame.Y);
+                }
+            }
+            else
+            {
+                if (_slider.Frame.X + offset >= 0 && _slider.Frame.X + offset + _slider.Frame.Width <= barSize)
+                {
+                    _wasSliderMouse = true;
+                    _slider.X = _slider.Frame.X + offset;
+                    Position = GetPositionFromSliderLocation (_slider.Frame.X);
+                }
+            }
+        }
+        else if (me.Flags == MouseFlags.Button1Released)
+        {
+            _lastLocation = -1;
+
+            if (Application.MouseGrabView == sender as View)
+            {
+                Application.UngrabMouse ();
+            }
+        }
+        else if ((me.Flags == MouseFlags.WheeledDown && Orientation == Orientation.Vertical)
+                 || (me.Flags == MouseFlags.WheeledRight && Orientation == Orientation.Horizontal))
+        {
+            Position = Math.Min (Position + 1, Size - barSize);
+        }
+        else if ((me.Flags == MouseFlags.WheeledUp && Orientation == Orientation.Vertical)
+                 || (me.Flags == MouseFlags.WheeledLeft && Orientation == Orientation.Horizontal))
+        {
+            Position = Math.Max (Position - 1, 0);
+        }
+        else if (me.Flags != MouseFlags.ReportMousePosition)
+        {
+            return;
+        }
+
+        e.Handled = true;
+    }
+
+    [CanBeNull]
+    private ColorScheme _savedColorScheme;
+
+    private void Slider_MouseEnter (object sender, MouseEventEventArgs e)
+    {
+        _savedColorScheme ??= _slider.ColorScheme;
+        _slider.ColorScheme = new ()
+        {
+            Normal = new (_savedColorScheme.HotNormal.Foreground, _savedColorScheme.HotNormal.Foreground),
+            Focus = new (_savedColorScheme.Focus.Foreground, _savedColorScheme.Focus.Foreground),
+            HotNormal = new (_savedColorScheme.Normal.Foreground, _savedColorScheme.Normal.Foreground),
+            HotFocus = new (_savedColorScheme.HotFocus.Foreground, _savedColorScheme.HotFocus.Foreground),
+            Disabled = new (_savedColorScheme.Disabled.Foreground, _savedColorScheme.Disabled.Foreground)
+        };
+    }
+
+    private void Slider_MouseLeave (object sender, MouseEventEventArgs e)
+    {
+        if (_savedColorScheme is { } && !e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Pressed))
+        {
+            _slider.ColorScheme = _savedColorScheme;
+            _savedColorScheme = null;
+        }
+    }
+
+    /// <inheritdoc/>
+    protected override void Dispose (bool disposing)
+    {
+        Added -= Scroll_Added;
+        Initialized -= Scroll_Initialized;
+        MouseEvent -= Scroll_MouseEvent;
+        _slider.MouseEvent -= Slider_MouseEvent;
+        _slider.MouseEnter -= Slider_MouseEnter;
+        _slider.MouseLeave -= Slider_MouseLeave;
+
+        base.Dispose (disposing);
+    }
+}

+ 1 - 1
Terminal.Gui/Views/ScrollBarView.cs

@@ -40,7 +40,7 @@ public class ScrollBarView : View
         WantContinuousButtonPressed = true;
         ClearOnVisibleFalse = false;
 
-        Added += (s, e) => CreateBottomRightCorner (e.Parent);
+        Added += (s, e) => CreateBottomRightCorner (e.SuperView);
         Initialized += ScrollBarView_Initialized;
     }
 

+ 1 - 1
UICatalog/KeyBindingsDialog.cs

@@ -209,7 +209,7 @@ internal class KeyBindingsDialog : Dialog
             // (and always was wrong). Parents don't get to be told when new views are added
             // to them
 
-            view.Added += (s, e) => RecordView (e.Child);
+            view.Added += (s, e) => RecordView (e.SubView);
         }
     }
 }

+ 1 - 1
UICatalog/Scenarios/Buttons.cs

@@ -440,7 +440,7 @@ public class Buttons : Scenario
                 Text = Value.ToString (),
                 X = Pos.Right (_down),
                 Y = Pos.Top (_down),
-                Width = Dim.Func (() => Digits),
+                Width = Dim.Func (() => _number is null ? Digits : Math.Max (Digits, _number.Text.Length)),
                 Height = 1,
                 TextAlignment = Alignment.Center,
                 CanFocus = true

+ 250 - 0
UICatalog/Scenarios/ScrollDemo.cs

@@ -0,0 +1,250 @@
+using Terminal.Gui;
+
+namespace UICatalog.Scenarios;
+
+[ScenarioMetadata ("Scroll Demo", "Demonstrates using Scroll view.")]
+[ScenarioCategory ("Drawing")]
+[ScenarioCategory ("Scrolling")]
+public class ScrollDemo : Scenario
+{
+    private ViewDiagnosticFlags _diagnosticFlags;
+
+    public override void Main ()
+    {
+        Application.Init ();
+
+        _diagnosticFlags = View.Diagnostics;
+
+        Window app = new ()
+        {
+            Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}"
+        };
+
+        var editor = new AdornmentsEditor ();
+        app.Add (editor);
+
+        var view = new FrameView
+        {
+            Title = "Demo View",
+            X = Pos.Right (editor),
+            Width = Dim.Fill (),
+            Height = Dim.Fill (),
+            ColorScheme = Colors.ColorSchemes ["Base"]
+        };
+        app.Add (view);
+
+        var scroll = new Scroll
+        {
+            X = Pos.AnchorEnd (),
+            Height = Dim.Fill (),
+        };
+        view.Add (scroll);
+
+        var lblWidthHeight = new Label
+        {
+            Text = "Width/Height:"
+        };
+        view.Add (lblWidthHeight);
+
+        Buttons.NumericUpDown<int> scrollWidthHeight = new ()
+        {
+            Value = scroll.Frame.Width,
+            X = Pos.Right (lblWidthHeight) + 1,
+            Y = Pos.Top (lblWidthHeight)
+        };
+        view.Add (scrollWidthHeight);
+
+        scrollWidthHeight.ValueChanging += (s, e) =>
+                                           {
+                                               if (e.NewValue < 1)
+                                               {
+                                                   e.Cancel = true;
+
+                                                   return;
+                                               }
+
+                                               if (scroll.Orientation == Orientation.Vertical)
+                                               {
+                                                   scroll.Width = e.NewValue;
+                                               }
+                                               else
+                                               {
+                                                   scroll.Height = e.NewValue;
+                                               }
+                                           };
+
+        var rgOrientation = new RadioGroup
+        {
+            Y = Pos.Bottom (lblWidthHeight),
+            RadioLabels = ["Vertical", "Horizontal"],
+            Orientation = Orientation.Horizontal
+        };
+        view.Add (rgOrientation);
+
+        rgOrientation.SelectedItemChanged += (s, e) =>
+                                             {
+                                                 if (e.SelectedItem == e.PreviousSelectedItem)
+                                                 {
+                                                     return;
+                                                 }
+
+                                                 if (rgOrientation.SelectedItem == 0)
+                                                 {
+                                                     scroll.Orientation = Orientation.Vertical;
+                                                     scroll.X = Pos.AnchorEnd ();
+                                                     scroll.Y = 0;
+                                                     scroll.Width = scrollWidthHeight.Value;
+                                                     scroll.Height = Dim.Fill ();
+                                                     scroll.Size /= 3;
+                                                 }
+                                                 else
+                                                 {
+                                                     scroll.Orientation = Orientation.Horizontal;
+                                                     scroll.X = 0;
+                                                     scroll.Y = Pos.AnchorEnd ();
+                                                     scroll.Width = Dim.Fill ();
+                                                     scroll.Height = scrollWidthHeight.Value;
+                                                     scroll.Size *= 3;
+                                                 }
+                                             };
+
+        var lblSize = new Label
+        {
+            Y = Pos.Bottom (rgOrientation),
+            Text = "Size:"
+        };
+        view.Add (lblSize);
+
+        Buttons.NumericUpDown<int> scrollSize = new ()
+        {
+            Value = scroll.Size,
+            X = Pos.Right (lblSize) + 1,
+            Y = Pos.Top (lblSize)
+        };
+        view.Add (scrollSize);
+
+        scrollSize.ValueChanging += (s, e) =>
+                                    {
+                                        if (e.NewValue < 0)
+                                        {
+                                            e.Cancel = true;
+
+                                            return;
+                                        }
+
+                                        if (scroll.Size != e.NewValue)
+                                        {
+                                            scroll.Size = e.NewValue;
+                                        }
+                                    };
+
+        var lblPosition = new Label
+        {
+            Y = Pos.Bottom (lblSize),
+            Text = "Position:"
+        };
+        view.Add (lblPosition);
+
+        Buttons.NumericUpDown<int> scrollPosition = new ()
+        {
+            Value = scroll.Position,
+            X = Pos.Right (lblPosition) + 1,
+            Y = Pos.Top (lblPosition)
+        };
+        view.Add (scrollPosition);
+
+        scrollPosition.ValueChanging += (s, e) =>
+                                        {
+                                            if (e.NewValue < 0)
+                                            {
+                                                e.Cancel = true;
+
+                                                return;
+                                            }
+
+                                            if (scroll.Position != e.NewValue)
+                                            {
+                                                scroll.Position = e.NewValue;
+                                            }
+
+                                            if (scroll.Position != e.NewValue)
+                                            {
+                                                e.Cancel = true;
+                                            }
+                                        };
+
+        var lblSizeChanged = new Label
+        {
+            Y = Pos.Bottom (lblPosition) + 1
+        };
+        view.Add (lblSizeChanged);
+
+        scroll.SizeChanged += (s, e) =>
+                              {
+                                  lblSizeChanged.Text = $"SizeChanged event - OldValue: {e.OldValue}; NewValue: {e.NewValue}";
+
+                                  if (scrollSize.Value != e.NewValue)
+                                  {
+                                      scrollSize.Value = e.NewValue;
+                                  }
+                              };
+
+        var lblPosChanging = new Label
+        {
+            Y = Pos.Bottom (lblSizeChanged)
+        };
+        view.Add (lblPosChanging);
+
+        scroll.PositionChanging += (s, e) => { lblPosChanging.Text = $"PositionChanging event - OldValue: {e.OldValue}; NewValue: {e.NewValue}"; };
+
+        var lblPositionChanged = new Label
+        {
+            Y = Pos.Bottom (lblPosChanging)
+        };
+        view.Add (lblPositionChanged);
+
+        scroll.PositionChanged += (s, e) =>
+                                  {
+                                      lblPositionChanged.Text = $"PositionChanged event - OldValue: {e.OldValue}; NewValue: {e.NewValue}";
+                                      scrollPosition.Value = e.NewValue;
+                                  };
+
+        var lblScrollFrame = new Label
+        {
+            Y = Pos.Bottom (lblPositionChanged) + 1
+        };
+        view.Add (lblScrollFrame);
+
+        var lblScrollViewport = new Label
+        {
+            Y = Pos.Bottom (lblScrollFrame)
+        };
+        view.Add (lblScrollViewport);
+
+        var lblScrollContentSize = new Label
+        {
+            Y = Pos.Bottom (lblScrollViewport)
+        };
+        view.Add (lblScrollContentSize);
+
+
+        scroll.LayoutComplete += (s, e) =>
+                                 {
+                                     lblScrollFrame.Text = $"Scroll Frame: {scroll.Frame.ToString ()}";
+                                     lblScrollViewport.Text = $"Scroll Viewport: {scroll.Viewport.ToString ()}";
+                                     lblScrollContentSize.Text = $"Scroll ContentSize: {scroll.GetContentSize ().ToString ()}";
+                                 };
+
+        editor.Initialized += (s, e) =>
+                              {
+                                  scroll.Size = int.Max (app.GetContentSize ().Height * 2, app.GetContentSize ().Width * 2);
+                                  editor.ViewToEdit = scroll;
+                              };
+
+        app.Closed += (s, e) => View.Diagnostics = _diagnosticFlags;
+
+        Application.Run (app);
+        app.Dispose ();
+        Application.Shutdown ();
+    }
+}

+ 13 - 13
UnitTests/View/SubviewTests.cs

@@ -17,15 +17,15 @@ public class SubviewTests
 
         v.Added += (s, e) =>
                    {
-                       Assert.Same (v.SuperView, e.Parent);
-                       Assert.Same (t, e.Parent);
-                       Assert.Same (v, e.Child);
+                       Assert.Same (v.SuperView, e.SuperView);
+                       Assert.Same (t, e.SuperView);
+                       Assert.Same (v, e.SubView);
                    };
 
         v.Removed += (s, e) =>
                      {
-                         Assert.Same (t, e.Parent);
-                         Assert.Same (v, e.Child);
+                         Assert.Same (t, e.SuperView);
+                         Assert.Same (v, e.SubView);
                          Assert.True (v.SuperView == null);
                      };
 
@@ -108,26 +108,26 @@ public class SubviewTests
 
         winAddedToTop.Added += (s, e) =>
                                {
-                                   Assert.Equal (e.Parent.Frame.Width, winAddedToTop.Frame.Width);
-                                   Assert.Equal (e.Parent.Frame.Height, winAddedToTop.Frame.Height);
+                                   Assert.Equal (e.SuperView.Frame.Width, winAddedToTop.Frame.Width);
+                                   Assert.Equal (e.SuperView.Frame.Height, winAddedToTop.Frame.Height);
                                };
 
         v1AddedToWin.Added += (s, e) =>
                               {
-                                  Assert.Equal (e.Parent.Frame.Width, v1AddedToWin.Frame.Width);
-                                  Assert.Equal (e.Parent.Frame.Height, v1AddedToWin.Frame.Height);
+                                  Assert.Equal (e.SuperView.Frame.Width, v1AddedToWin.Frame.Width);
+                                  Assert.Equal (e.SuperView.Frame.Height, v1AddedToWin.Frame.Height);
                               };
 
         v2AddedToWin.Added += (s, e) =>
                               {
-                                  Assert.Equal (e.Parent.Frame.Width, v2AddedToWin.Frame.Width);
-                                  Assert.Equal (e.Parent.Frame.Height, v2AddedToWin.Frame.Height);
+                                  Assert.Equal (e.SuperView.Frame.Width, v2AddedToWin.Frame.Width);
+                                  Assert.Equal (e.SuperView.Frame.Height, v2AddedToWin.Frame.Height);
                               };
 
         svAddedTov1.Added += (s, e) =>
                              {
-                                 Assert.Equal (e.Parent.Frame.Width, svAddedTov1.Frame.Width);
-                                 Assert.Equal (e.Parent.Frame.Height, svAddedTov1.Frame.Height);
+                                 Assert.Equal (e.SuperView.Frame.Width, svAddedTov1.Frame.Width);
+                                 Assert.Equal (e.SuperView.Frame.Height, svAddedTov1.Frame.Height);
                              };
 
         top.Initialized += (s, e) =>

+ 917 - 0
UnitTests/Views/ScrollTests.cs

@@ -0,0 +1,917 @@
+using Xunit.Abstractions;
+
+namespace Terminal.Gui.ViewsTests;
+
+public class ScrollTests
+{
+    public ScrollTests (ITestOutputHelper output) { _output = output; }
+    private readonly ITestOutputHelper _output;
+
+    [Theory]
+    [AutoInitShutdown]
+    [InlineData (
+                    20,
+                    @"
+█
+█
+█
+█
+█
+░
+░
+░
+░
+░",
+                    @"
+░
+░
+█
+█
+█
+█
+█
+░
+░
+░",
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█",
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    @"
+█████░░░░░",
+                    @"
+░░█████░░░",
+                    @"
+░░░░░█████",
+                    @"
+░░██░░░░░░")]
+    [InlineData (
+                    40,
+                    @"
+█
+█
+░
+░
+░
+░
+░
+░
+░
+░",
+                    @"
+░
+█
+█
+░
+░
+░
+░
+░
+░
+░",
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    @"
+░
+█
+░
+░
+░
+░
+░
+░
+░
+░",
+                    @"
+██░░░░░░░░",
+                    @"
+░██░░░░░░░",
+                    @"
+░░██░░░░░░",
+                    @"
+░█░░░░░░░░")]
+    public void Changing_Position_Size_Orientation_Draws_Correctly (
+        int size,
+        string firstVertExpected,
+        string middleVertExpected,
+        string endVertExpected,
+        string sizeVertExpected,
+        string firstHoriExpected,
+        string middleHoriExpected,
+        string endHoriExpected,
+        string sizeHoriExpected
+    )
+    {
+        var scroll = new Scroll
+        {
+            Orientation = Orientation.Vertical,
+            Size = size,
+            Height = 10
+        };
+        var top = new Toplevel ();
+        top.Add (scroll);
+        Application.Begin (top);
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (firstVertExpected, _output);
+
+        scroll.Position = 4;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (middleVertExpected, _output);
+
+        scroll.Position = 10;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (endVertExpected, _output);
+
+        scroll.Size = size * 2;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (sizeVertExpected, _output);
+
+        scroll.Orientation = Orientation.Horizontal;
+        scroll.Width = 10;
+        scroll.Height = 1;
+        scroll.Position = 0;
+        scroll.Size = size;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (firstHoriExpected, _output);
+
+        scroll.Position = 4;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (middleHoriExpected, _output);
+
+        scroll.Position = 10;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (endHoriExpected, _output);
+
+        scroll.Size = size * 2;
+        Application.Refresh ();
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (sizeHoriExpected, _output);
+    }
+
+    [Fact]
+    public void Constructor_Defaults ()
+    {
+        var scroll = new Scroll ();
+        Assert.True (scroll.WantContinuousButtonPressed);
+        Assert.False (scroll.CanFocus);
+        Assert.Equal (Orientation.Vertical, scroll.Orientation);
+        Assert.Equal (0, scroll.Size);
+        Assert.Equal (0, scroll.Position);
+    }
+
+    [Theory]
+    [AutoInitShutdown]
+    [InlineData (
+                    Orientation.Vertical,
+                    20,
+                    10,
+                    4,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█",
+                    0,
+                    @"
+█
+█
+█
+█
+█
+░
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    5,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    20,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    20,
+                    10,
+                    4,
+                    @"
+░░░░░█████",
+                    0,
+                    @"
+█████░░░░░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    5,
+                    @"
+░░██░░░░░░",
+                    20,
+                    @"
+░░░░░██░░░")]
+    public void Mouse_On_The_Container (Orientation orientation, int size, int position, int location, string output, int expectedPos, string expectedOut)
+    {
+        var scroll = new Scroll
+        {
+            Width = orientation == Orientation.Vertical ? 1 : 10,
+            Height = orientation == Orientation.Vertical ? 10 : 1,
+            Orientation = orientation, Size = size,
+            Position = position
+        };
+        var top = new Toplevel ();
+        top.Add (scroll);
+        Application.Begin (top);
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (output, _output);
+
+        Application.OnMouseEvent (
+                                  new ()
+                                  {
+                                      Position = orientation == Orientation.Vertical ? new (0, location) : new Point (location, 0),
+                                      Flags = MouseFlags.Button1Pressed
+                                  });
+        Assert.Equal (expectedPos, scroll.Position);
+
+        Application.Refresh ();
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (expectedOut, _output);
+    }
+
+    [Theory]
+    [AutoInitShutdown]
+    [InlineData (
+                    Orientation.Vertical,
+                    20,
+                    10,
+                    5,
+                    5,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█",
+                    MouseFlags.Button1Pressed,
+                    10,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    3,
+                    3,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    MouseFlags.Button1Pressed,
+                    10,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    20,
+                    10,
+                    5,
+                    5,
+                    @"
+░░░░░█████",
+                    MouseFlags.Button1Pressed,
+                    10,
+                    @"
+░░░░░█████")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    3,
+                    3,
+                    @"
+░░██░░░░░░",
+                    MouseFlags.Button1Pressed,
+                    10,
+                    @"
+░░██░░░░░░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    20,
+                    10,
+                    5,
+                    4,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    8,
+                    @"
+░
+░
+░
+░
+█
+█
+█
+█
+█
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    20,
+                    10,
+                    5,
+                    4,
+                    @"
+░░░░░█████",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    8,
+                    @"
+░░░░█████░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    20,
+                    10,
+                    5,
+                    6,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    10,
+                    @"
+░
+░
+░
+░
+░
+█
+█
+█
+█
+█")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    20,
+                    10,
+                    5,
+                    6,
+                    @"
+░░░░░█████",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    10,
+                    @"
+░░░░░█████")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    2,
+                    1,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    4,
+                    @"
+░
+█
+█
+░
+░
+░
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    2,
+                    1,
+                    @"
+░░██░░░░░░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    4,
+                    @"
+░██░░░░░░░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    3,
+                    4,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    12,
+                    @"
+░
+░
+░
+█
+█
+░
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    3,
+                    4,
+                    @"
+░░██░░░░░░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    12,
+                    @"
+░░░██░░░░░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    2,
+                    3,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    12,
+                    @"
+░
+░
+░
+█
+█
+░
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    2,
+                    3,
+                    @"
+░░██░░░░░░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    12,
+                    @"
+░░░██░░░░░")]
+    [InlineData (
+                    Orientation.Vertical,
+                    40,
+                    10,
+                    2,
+                    4,
+                    @"
+░
+░
+█
+█
+░
+░
+░
+░
+░
+░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    16,
+                    @"
+░
+░
+░
+░
+█
+█
+░
+░
+░
+░")]
+    [InlineData (
+                    Orientation.Horizontal,
+                    40,
+                    10,
+                    2,
+                    4,
+                    @"
+░░██░░░░░░",
+                    MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition,
+                    16,
+                    @"
+░░░░██░░░░")]
+    public void Mouse_On_The_Slider (
+        Orientation orientation,
+        int size,
+        int position,
+        int startLocation,
+        int endLocation,
+        string output,
+        MouseFlags mouseFlags,
+        int expectedPos,
+        string expectedOut
+    )
+    {
+        var scroll = new Scroll
+        {
+            Width = orientation == Orientation.Vertical ? 1 : 10,
+            Height = orientation == Orientation.Vertical ? 10 : 1,
+            Orientation = orientation,
+            Size = size, Position = position
+        };
+        var top = new Toplevel ();
+        top.Add (scroll);
+        Application.Begin (top);
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (output, _output);
+
+        Assert.Null (Application.MouseGrabView);
+
+        if (mouseFlags.HasFlag (MouseFlags.ReportMousePosition))
+        {
+            MouseFlags mf = mouseFlags & ~MouseFlags.ReportMousePosition;
+
+            Application.OnMouseEvent (
+                                      new ()
+                                      {
+                                          Position = orientation == Orientation.Vertical ? new (0, startLocation) : new (startLocation, 0),
+                                          Flags = mf
+                                      });
+
+            Application.OnMouseEvent (
+                                      new ()
+                                      {
+                                          Position = orientation == Orientation.Vertical ? new (0, endLocation) : new (endLocation, 0),
+                                          Flags = mouseFlags
+                                      });
+        }
+        else
+        {
+            Assert.Equal (startLocation, endLocation);
+
+            Application.OnMouseEvent (
+                                      new ()
+                                      {
+                                          Position = orientation == Orientation.Vertical ? new (0, startLocation) : new (startLocation, 0),
+                                          Flags = mouseFlags
+                                      });
+        }
+
+        Assert.Equal ("slider", Application.MouseGrabView?.Id);
+        Assert.Equal (expectedPos, scroll.Position);
+
+        Application.Refresh ();
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (expectedOut, _output);
+
+        Application.OnMouseEvent (
+                                  new ()
+                                  {
+                                      Position = orientation == Orientation.Vertical ? new (0, startLocation) : new (startLocation, 0),
+                                      Flags = MouseFlags.Button1Released
+                                  });
+        Assert.Null (Application.MouseGrabView);
+    }
+
+    [Theory]
+    [InlineData (Orientation.Vertical, 20, 10)]
+    [InlineData (Orientation.Vertical, 40, 30)]
+    public void Position_Cannot_Be_Negative_Nor_Greater_Than_Size_Minus_Frame_Length (Orientation orientation, int size, int expectedPos)
+    {
+        var scroll = new Scroll { Orientation = orientation, Height = 10, Size = size };
+        Assert.Equal (0, scroll.Position);
+
+        scroll.Position = -1;
+        Assert.Equal (0, scroll.Position);
+
+        scroll.Position = size;
+        Assert.Equal (0, scroll.Position);
+
+        scroll.Position = expectedPos;
+        Assert.Equal (expectedPos, scroll.Position);
+    }
+
+    [Fact]
+    public void PositionChanging_Cancelable_And_PositionChanged_Events ()
+    {
+        var changingCount = 0;
+        var changedCount = 0;
+        var scroll = new Scroll { Size = 10 };
+
+        scroll.PositionChanging += (s, e) =>
+                                   {
+                                       if (changingCount == 0)
+                                       {
+                                           e.Cancel = true;
+                                       }
+
+                                       changingCount++;
+                                   };
+        scroll.PositionChanged += (s, e) => changedCount++;
+
+        scroll.Position = 1;
+        Assert.Equal (0, scroll.Position);
+        Assert.Equal (1, changingCount);
+        Assert.Equal (0, changedCount);
+
+        scroll.Position = 1;
+        Assert.Equal (1, scroll.Position);
+        Assert.Equal (2, changingCount);
+        Assert.Equal (1, changedCount);
+    }
+
+    [Fact]
+    public void SizeChanged_Event ()
+    {
+        var count = 0;
+        var scroll = new Scroll ();
+        scroll.SizeChanged += (s, e) => count++;
+
+        scroll.Size = 10;
+        Assert.Equal (10, scroll.Size);
+        Assert.Equal (1, count);
+    }
+
+    [Theory]
+    [AutoInitShutdown]
+    [InlineData (
+                    3,
+                    10,
+                    1,
+                    Orientation.Vertical,
+                    @"
+┌─┐
+│█│
+│█│
+│█│
+│░│
+│░│
+│░│
+│░│
+│░│
+└─┘")]
+    [InlineData (
+                    10,
+                    3,
+                    1,
+                    Orientation.Horizontal,
+                    @"
+┌────────┐
+│███░░░░░│
+└────────┘")]
+    [InlineData (
+                    3,
+                    10,
+                    3,
+                    Orientation.Vertical,
+                    @"
+┌───┐
+│███│
+│███│
+│███│
+│░░░│
+│░░░│
+│░░░│
+│░░░│
+│░░░│
+└───┘")]
+    [InlineData (
+                    10,
+                    3,
+                    3,
+                    Orientation.Horizontal,
+                    @"
+┌────────┐
+│███░░░░░│
+│███░░░░░│
+│███░░░░░│
+└────────┘")]
+    public void Vertical_Horizontal_Draws_Correctly (int sizeWidth, int sizeHeight, int widthHeight, Orientation orientation, string expected)
+    {
+        var super = new Window { Id = "super", Width = Dim.Fill (), Height = Dim.Fill () };
+        var top = new Toplevel ();
+        top.Add (super);
+
+        var scroll = new Scroll
+        {
+            Orientation = orientation,
+            Size = orientation == Orientation.Vertical ? sizeHeight * 2 : sizeWidth * 2,
+            Width = orientation == Orientation.Vertical ? widthHeight : Dim.Fill (),
+            Height = orientation == Orientation.Vertical ? Dim.Fill () : widthHeight
+        };
+        super.Add (scroll);
+
+        Application.Begin (top);
+
+        ((FakeDriver)Application.Driver).SetBufferSize (
+                                                        sizeWidth + (orientation == Orientation.Vertical ? widthHeight - 1 : 0),
+                                                        sizeHeight + (orientation == Orientation.Vertical ? 0 : widthHeight - 1));
+
+        _ = TestHelpers.AssertDriverContentsWithFrameAre (expected, _output);
+    }
+
+    [Fact]
+    public void PositionChanging_PositionChanged_Events_Only_Raises_Once_If_Position_Was_Really_Changed ()
+    {
+        var changing = 0;
+        var cancel = false;
+        var changed = 0;
+        var scroll = new Scroll { Height = 10, Size = 20 };
+        scroll.PositionChanging += Scroll_PositionChanging;
+        scroll.PositionChanged += Scroll_PositionChanged;
+
+        Assert.Equal (Orientation.Vertical, scroll.Orientation);
+        Assert.Equal (new (0, 0, 1, 10), scroll.Viewport);
+        Assert.Equal (0, scroll.Position);
+        Assert.Equal (0, changing);
+        Assert.Equal (0, changed);
+
+        scroll.Position = 0;
+        Assert.Equal (0, scroll.Position);
+        Assert.Equal (0, changing);
+        Assert.Equal (0, changed);
+
+        scroll.Position = 1;
+        Assert.Equal (1, scroll.Position);
+        Assert.Equal (1, changing);
+        Assert.Equal (1, changed);
+
+        Reset ();
+        cancel = true;
+        scroll.Position = 2;
+        Assert.Equal (1, scroll.Position);
+        Assert.Equal (1, changing);
+        Assert.Equal (0, changed);
+
+        Reset ();
+        scroll.Position = 10;
+        Assert.Equal (10, scroll.Position);
+        Assert.Equal (1, changing);
+        Assert.Equal (1, changed);
+
+        Reset ();
+        scroll.Position = 11;
+        Assert.Equal (10, scroll.Position);
+        Assert.Equal (0, changing);
+        Assert.Equal (0, changed);
+
+        Reset ();
+        scroll.Position = 0;
+        Assert.Equal (0, scroll.Position);
+        Assert.Equal (1, changing);
+        Assert.Equal (1, changed);
+
+        scroll.PositionChanging -= Scroll_PositionChanging;
+        scroll.PositionChanged -= Scroll_PositionChanged;
+
+
+        void Scroll_PositionChanging (object sender, StateEventArgs<int> e)
+        {
+            changing++;
+            e.Cancel = cancel;
+        }
+
+        void Scroll_PositionChanged (object sender, StateEventArgs<int> e) => changed++;
+
+        void Reset ()
+        {
+            changing = 0;
+            cancel = false;
+            changed = 0;
+        }
+    }
+}