#nullable enable using System.ComponentModel; namespace Terminal.Gui; /// /// Indicates the size of scrollable content and controls the position of the visible content, either vertically or /// horizontally. /// Two s are provided, one to scroll up or left and one to scroll down or right. Between the /// buttons is a that can be dragged to /// control the position of the visible content. The ScrollSlier is sized to show the proportion of the scrollable /// content to the size of the . /// /// /// /// See the Scrolling Deep Dive. /// /// /// By default, the built-in View scrollbars (/ /// ) have both and set to /// . /// To enable them, either set set to or explicitly set /// /// to . /// /// /// By default, this view cannot be focused and does not support keyboard input. /// /// public class ScrollBar : View, IOrientation, IDesignable { private readonly Button _decreaseButton; private readonly ScrollSlider _slider; private readonly Button _increaseButton; /// public ScrollBar () { // Set the default width and height based on the orientation - fill Viewport Width = Dim.Auto ( DimAutoStyle.Content, Dim.Func (() => Orientation == Orientation.Vertical ? 1 : SuperView?.Viewport.Width ?? 0)); Height = Dim.Auto ( DimAutoStyle.Content, Dim.Func (() => Orientation == Orientation.Vertical ? SuperView?.Viewport.Height ?? 0 : 1)); _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; Add (_decreaseButton, _slider, _increaseButton); CanFocus = false; _orientationHelper = new (this); // Do not use object initializer! _orientationHelper.Orientation = Orientation.Vertical; // This sets the width/height etc... OnOrientationChanged (Orientation); return; 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) { ShowHide (); } private void ShowHide () { if (AutoShow) { Visible = VisibleContentSize < ScrollableContentSize; } _slider.VisibleContentSize = VisibleContentSize; _slider.Size = CalculateSliderSize (); _sliderPosition = CalculateSliderPositionFromContentPosition (_position); _slider.Position = _sliderPosition.Value; } 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; _slider.Orientation = newOrientation; PositionSubviews (); OrientationChanged?.Invoke (this, new (newOrientation)); } #endregion /// /// Gets or sets the amount each mouse wheel event, or click on the increment/decrement buttons, will /// incremenet/decrement the . /// /// /// The default is 1. /// public int Increment { get; set; } = 1; // AutoShow should be false by default. Views should not be hidden by default. private bool _autoShow; /// /// Gets or sets whether will be set to if the dimension of the /// scroll bar is less than and if greater than or equal /// to. /// /// /// The default is . /// public bool AutoShow { get => _autoShow; set { if (_autoShow != value) { _autoShow = value; if (!AutoShow) { Visible = true; } ShowHide (); SetNeedsLayout (); } } } private int? _visibleContentSize; /// /// Gets or sets the size of the visible viewport into the content being scrolled, bounded by /// . /// /// /// If not explicitly set, the visible content size will be appropriate dimension of the ScrollBar'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 (); ShowHide (); } } private int? _scrollableContentSize; /// /// Gets or sets the size of the content that can be scrolled. This is typically set to /// . /// public int ScrollableContentSize { get { if (_scrollableContentSize.HasValue) { return _scrollableContentSize.Value; } return Orientation == Orientation.Vertical ? SuperView?.GetContentSize ().Height ?? 0 : SuperView?.GetContentSize ().Width ?? 0; } set { if (value == _scrollableContentSize || value < 0) { return; } _scrollableContentSize = value; _slider.Size = CalculateSliderSize (); ShowHide (); if (!Visible) { return; } OnSizeChanged (value); ScrollableContentSizeChanged?.Invoke (this, new (in value)); 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 || !Visible) { return; } // Clamp the value between 0 and Size - VisibleContentSize int newContentPosition = 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; if (_position == newContentPosition) { return; } _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)); SetNeedsLayout (); } } /// /// 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 () { return 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 // TODO: This logic mostly works to provide a proportional jump. However, the math // TODO: falls apart in edge cases. Most other scroll bars (e.g. Windows) do not do proportional // TODO: Thus, this is disabled; 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; } }