#nullable enable using System.ComponentModel; using System.Drawing; namespace Terminal.Gui; /// /// Indicates the size of scrollable content and provides a visible element, referred to as the "ScrollSlider" that /// that is sized to /// show the proportion of the scrollable content to the size of the . The ScrollSlider /// can be dragged with the mouse. A Scroll can be oriented either vertically or horizontally and is used within a /// . /// /// /// /// By default, this view cannot be focused and does not support keyboard. /// /// public class ScrollBar : View, IOrientation, IDesignable { private readonly Button _decreaseButton; internal readonly ScrollSlider _slider; private readonly Button _increaseButton; /// public ScrollBar () { _decreaseButton = new () { CanFocus = false, NoDecorations = true, NoPadding = true, ShadowStyle = ShadowStyle.None, WantContinuousButtonPressed = true }; _decreaseButton.Accepting += OnDecreaseButtonOnAccept; _slider = new () { SliderPadding = 2, // For the buttons }; _slider.Scrolled += SliderOnScroll; _slider.PositionChanged += SliderOnPositionChanged; _increaseButton = new () { CanFocus = false, NoDecorations = true, NoPadding = true, ShadowStyle = ShadowStyle.None, WantContinuousButtonPressed = true }; _increaseButton.Accepting += OnIncreaseButtonOnAccept; base.Add (_decreaseButton, _slider, _increaseButton); CanFocus = false; _orientationHelper = new (this); // Do not use object initializer! _orientationHelper.Orientation = Orientation.Vertical; //_orientationHelper.OrientationChanging += (sender, e) => OrientationChanging?.Invoke (this, e); //_orientationHelper.OrientationChanged += (sender, e) => OrientationChanged?.Invoke (this, e); // This sets the width/height etc... OnOrientationChanged (Orientation); void OnDecreaseButtonOnAccept (object? s, CommandEventArgs e) { Position -= Increment; e.Cancel = true; } void OnIncreaseButtonOnAccept (object? s, CommandEventArgs e) { Position += Increment; e.Cancel = true; } } /// protected override void OnFrameChanged (in Rectangle frame) { if (Orientation == Orientation.Vertical) { _slider.VisibleContentSize = Viewport.Height; } else { _slider.VisibleContentSize = Viewport.Width; } _slider.Size = CalculateSliderSize (); ShowHide (); } private void ShowHide () { if (!AutoHide) { return; } if (Orientation == Orientation.Vertical) { Visible = Frame.Height < ScrollableContentSize; } else { Visible = Frame.Width < ScrollableContentSize; } _slider.Size = CalculateSliderSize (); _sliderPosition = CalculateSliderPositionFromContentPosition (_position, NavigationDirection.Forward); _slider.Position = _sliderPosition.Value; } /// protected override void OnSubviewLayout (LayoutEventArgs args) { } private void PositionSubviews () { if (Orientation == Orientation.Vertical) { _decreaseButton.Y = 0; _decreaseButton.X = 0; _decreaseButton.Width = Dim.Fill (); _decreaseButton.Height = 1; _decreaseButton.Title = Glyphs.UpArrow.ToString (); _slider.X = 0; _slider.Y = 1; _slider.Width = Dim.Fill (); _increaseButton.Y = Pos.AnchorEnd (); _increaseButton.X = 0; _increaseButton.Width = Dim.Fill (); _increaseButton.Height = 1; _increaseButton.Title = Glyphs.DownArrow.ToString (); } else { _decreaseButton.Y = 0; _decreaseButton.X = 0; _decreaseButton.Width = 1; _decreaseButton.Height = Dim.Fill (); _decreaseButton.Title = Glyphs.LeftArrow.ToString (); _slider.Y = 0; _slider.X = 1; _slider.Height = Dim.Fill (); _increaseButton.Y = 0; _increaseButton.X = Pos.AnchorEnd (); _increaseButton.Width = 1; _increaseButton.Height = Dim.Fill (); _increaseButton.Title = Glyphs.RightArrow.ToString (); } } #region IOrientation members private readonly OrientationHelper _orientationHelper; /// public Orientation Orientation { get => _orientationHelper.Orientation; set => _orientationHelper.Orientation = value; } /// public event EventHandler>? OrientationChanging; /// public event EventHandler>? OrientationChanged; /// public void OnOrientationChanged (Orientation newOrientation) { TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom; TextAlignment = Alignment.Center; VerticalTextAlignment = Alignment.Center; X = 0; Y = 0; if (Orientation == Orientation.Vertical) { Width = 1; Height = Dim.Fill (); } else { Width = Dim.Fill (); Height = 1; } _slider.Orientation = newOrientation; PositionSubviews (); OrientationChanged?.Invoke (this, new (newOrientation)); } #endregion /// /// Gets or sets the amount each mouse wheel event will incremenet/decrement the . /// /// /// The default is 1. /// public int Increment { get; set; } = 1; private bool _autoHide = true; /// /// Gets or sets whether will be set to if the dimension of the /// scroll bar is greater than or equal to . /// public bool AutoHide { get => _autoHide; set { if (_autoHide != value) { _autoHide = value; if (!AutoHide) { Visible = true; } SetNeedsLayout (); } } } public bool KeepContentInAllViewport { //get => _scroll.KeepContentInAllViewport; //set => _scroll.KeepContentInAllViewport = value; get; set; } /// /// Gets or sets whether the Scroll will show the percentage the slider /// takes up within the . /// public bool ShowPercent { get => _slider.ShowPercent; set => _slider.ShowPercent = value; } private int? _visibleContentSize; /// /// Gets or sets the size of the visible viewport into the content being scrolled, bounded by . /// /// /// If not explicitly set, will be the appropriate dimension of the Scroll's Frame. /// public int VisibleContentSize { get { if (_visibleContentSize.HasValue) { return _visibleContentSize.Value; } return Orientation == Orientation.Vertical ? Frame.Height : Frame.Width; } set { _visibleContentSize = value; _slider.Size = CalculateSliderSize (); } } private int _scrollableContentSize; /// /// Gets or sets the size of the content that can be scrolled. This is typically set to . /// public int ScrollableContentSize { get => _scrollableContentSize; set { if (value == _scrollableContentSize || value < 0) { return; } _scrollableContentSize = value; _slider.Size = CalculateSliderSize (); OnSizeChanged (_scrollableContentSize); ScrollableContentSizeChanged?.Invoke (this, new (in _scrollableContentSize)); SetNeedsLayout (); } } /// Called when has changed. protected virtual void OnSizeChanged (int size) { } /// Raised when has changed. public event EventHandler>? ScrollableContentSizeChanged; #region Position private int _position; /// /// Gets or sets the position of the slider relative to . /// /// /// /// The content position is clamped to 0 and minus . /// /// /// Setting will result in the and /// events being raised. /// /// public int Position { get => _position; set { if (value == _position) { return; } // Clamp the value between 0 and Size - VisibleContentSize int newContentPosition = (int)Math.Clamp (value, 0, Math.Max (0, ScrollableContentSize - VisibleContentSize)); NavigationDirection direction = newContentPosition >= _position ? NavigationDirection.Forward : NavigationDirection.Backward; if (OnPositionChanging (_position, newContentPosition)) { return; } CancelEventArgs args = new (ref _position, ref newContentPosition); PositionChanging?.Invoke (this, args); if (args.Cancel) { return; } int distance = newContentPosition - _position; _position = newContentPosition; _sliderPosition = CalculateSliderPositionFromContentPosition (_position, direction); if (_slider.Position != _sliderPosition) { _slider.Position = _sliderPosition.Value; } OnPositionChanged (_position); PositionChanged?.Invoke (this, new (in _position)); OnScrolled (distance); Scrolled?.Invoke (this, new (in distance)); } } /// /// Called when is changing. Return true to cancel the change. /// protected virtual bool OnPositionChanging (int currentPos, int newPos) { return false; } /// /// Raised when the is changing. Set to /// to prevent the position from being changed. /// public event EventHandler>? PositionChanging; /// Called when has changed. protected virtual void OnPositionChanged (int position) { } /// Raised when the has changed. public event EventHandler>? PositionChanged; /// Called when has changed. Indicates how much to scroll. protected virtual void OnScrolled (int distance) { } /// Raised when the has changed. Indicates how much to scroll. public event EventHandler>? Scrolled; /// /// INTERNAL API (for unit tests) - Calculates the position within the based on the slider position. /// /// /// Clamps the sliderPosition, ensuring the returned content position is always less than /// - . /// /// /// internal int CalculatePositionFromSliderPosition (int sliderPosition) { int scrollBarSize = Orientation == Orientation.Vertical ? Viewport.Height : Viewport.Width; return ScrollSlider.CalculateContentPosition (ScrollableContentSize, VisibleContentSize, sliderPosition, scrollBarSize - _slider.SliderPadding); } #endregion ContentPosition #region Slider Management private int? _sliderPosition; /// /// INTERNAL (for unit tests). Calculates the size of the slider based on the Orientation, VisibleContentSize, the actual Viewport, and Size. /// /// internal int CalculateSliderSize () { int maxSliderSize = (Orientation == Orientation.Vertical ? Viewport.Height : Viewport.Width) - 2; return ScrollSlider.CalculateSize (ScrollableContentSize, VisibleContentSize, maxSliderSize); } private void SliderOnPositionChanged (object? sender, EventArgs e) { if (VisibleContentSize == 0) { return; } RaiseSliderPositionChangeEvents (_sliderPosition, e.CurrentValue); } private void SliderOnScroll (object? sender, EventArgs e) { if (VisibleContentSize == 0) { return; } int calculatedSliderPos = CalculateSliderPositionFromContentPosition (_position, e.CurrentValue >= 0 ? NavigationDirection.Forward : NavigationDirection.Backward); if (calculatedSliderPos == _sliderPosition) { return; } int sliderScrolledAmount = e.CurrentValue; int calculatedPosition = CalculatePositionFromSliderPosition (calculatedSliderPos + sliderScrolledAmount); Position = calculatedPosition; } /// /// Gets or sets the position of the start of the Scroll slider, within the Viewport. /// public int GetSliderPosition () => CalculateSliderPositionFromContentPosition (_position); private void RaiseSliderPositionChangeEvents (int? currentSliderPosition, int newSliderPosition) { if (currentSliderPosition == newSliderPosition) { return; } _sliderPosition = newSliderPosition; OnSliderPositionChanged (newSliderPosition); SliderPositionChanged?.Invoke (this, new (in newSliderPosition)); } /// Called when the slider position has changed. protected virtual void OnSliderPositionChanged (int position) { } /// Raised when the slider position has changed. public event EventHandler>? SliderPositionChanged; /// /// INTERNAL API (for unit tests) - Calculates the position of the slider based on the content position. /// /// /// /// internal int CalculateSliderPositionFromContentPosition (int contentPosition, NavigationDirection direction = NavigationDirection.Forward) { int scrollBarSize = Orientation == Orientation.Vertical ? Viewport.Height : Viewport.Width; return ScrollSlider.CalculatePosition (ScrollableContentSize, VisibleContentSize, contentPosition, scrollBarSize - 2, direction); } #endregion Slider Management /// protected override bool OnClearingViewport () { if (Orientation == Orientation.Vertical) { FillRect (Viewport with { Y = Viewport.Y + 1, Height = Viewport.Height - 2 }, Glyphs.Stipple); } else { FillRect (Viewport with { X = Viewport.X + 1, Width = Viewport.Width - 2 }, Glyphs.Stipple); } SetNeedsDraw (); return true; } // TODO: Change this to work OnMouseEvent with continuouse press and grab so it's continous. /// protected override bool OnMouseClick (MouseEventArgs args) { // Check if the mouse click is a single click if (!args.IsSingleClicked) { return false; } int sliderCenter; int distanceFromCenter; if (Orientation == Orientation.Vertical) { sliderCenter = 1 + _slider.Frame.Y + _slider.Frame.Height / 2; distanceFromCenter = args.Position.Y - sliderCenter; } else { sliderCenter = 1 + _slider.Frame.X + _slider.Frame.Width / 2; distanceFromCenter = args.Position.X - sliderCenter; } #if PROPORTIONAL_SCROLL_JUMP // BUGBUG: This logic mostly works to provide a proportional jump. However, the math // BUGBUG: falls apart in edge cases. Most other scroll bars (e.g. Windows) do not do proportional // BUGBUG: Thus, this is disabled and we just jump a page each click. // Ratio of the distance to the viewport dimension double ratio = (double)Math.Abs (distanceFromCenter) / (VisibleContentSize); // Jump size based on the ratio and the total content size int jump = (int)(ratio * (Size - VisibleContentSize)); #else int jump = (VisibleContentSize); #endif // Adjust the content position based on the distance if (distanceFromCenter < 0) { Position = Math.Max (0, Position - jump); } else { Position = Math.Min (ScrollableContentSize - _slider.VisibleContentSize, Position + jump); } return true; } /// protected override bool OnMouseEvent (MouseEventArgs mouseEvent) { if (SuperView is null) { return false; } if (!mouseEvent.IsWheel) { return false; } if (Orientation == Orientation.Vertical) { if (mouseEvent.Flags.HasFlag (MouseFlags.WheeledDown)) { Position += Increment; } if (mouseEvent.Flags.HasFlag (MouseFlags.WheeledUp)) { Position -= Increment; } } else { if (mouseEvent.Flags.HasFlag (MouseFlags.WheeledRight)) { Position += Increment; } if (mouseEvent.Flags.HasFlag (MouseFlags.WheeledLeft)) { Position -= Increment; } } return true; } /// public bool EnableForDesign () { OrientationChanged += (sender, args) => { if (args.CurrentValue == Orientation.Vertical) { Width = 1; Height = Dim.Fill (); } else { Width = Dim.Fill (); Height = 1; } }; Width = 1; Height = Dim.Fill (); ScrollableContentSize = 250; return true; } }