diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs index b5fd61084..f7563327c 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs @@ -627,7 +627,7 @@ internal class CursesDriver : ConsoleDriver { return true; }); - _mainLoop.WinChanged += ProcessInput; + _mainLoop.WinChanged = ProcessInput; } public override void Init (Action terminalResized) diff --git a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs index b824663c4..b08958bd6 100644 --- a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs @@ -353,7 +353,7 @@ public class FakeDriver : ConsoleDriver { _keyUpHandler = keyUpHandler; // Note: Net doesn't support keydown/up events and thus any passed keyDown/UpHandlers will never be called - (mainLoop.MainLoopDriver as FakeMainLoop).KeyPressed += (consoleKey) => ProcessInput (consoleKey); + (mainLoop.MainLoopDriver as FakeMainLoop).KeyPressed = (consoleKey) => ProcessInput (consoleKey); } void ProcessInput (ConsoleKeyInfo consoleKey) diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index 1d10f98ce..2a9019b27 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -136,7 +136,7 @@ internal class NetEvents : IDisposable { public InputResult? DequeueInput () { - while (!_inputReadyCancellationTokenSource.Token.IsCancellationRequested) { + while (_inputReadyCancellationTokenSource != null && !_inputReadyCancellationTokenSource.Token.IsCancellationRequested) { _waitForStart.Set (); _winChange.Set (); @@ -185,7 +185,12 @@ internal class NetEvents : IDisposable { { while (!_inputReadyCancellationTokenSource.Token.IsCancellationRequested) { - _waitForStart.Wait (_inputReadyCancellationTokenSource.Token); + try { + _waitForStart.Wait (_inputReadyCancellationTokenSource.Token); + } catch (OperationCanceledException) { + + return; + } _waitForStart.Reset (); if (_inputQueue.Count == 0) { @@ -1339,9 +1344,14 @@ internal class NetMainLoop : IMainLoopDriver { } catch (OperationCanceledException) { return; } finally { - _waitForProbe.Reset (); + if (_waitForProbe.IsSet) { + _waitForProbe.Reset (); + } } + if (_inputHandlerTokenSource.IsCancellationRequested) { + return; + } if (_resultQueue.Count == 0) { _resultQueue.Enqueue (_netEvents.DequeueInput ()); } diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index 891887563..18e6492b9 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -861,9 +861,9 @@ internal class WindowsDriver : ConsoleDriver { _mouseHandler = mouseHandler; _mainLoop = mainLoop.MainLoopDriver as WindowsMainLoop; - _mainLoop.ProcessInput += ProcessInput; + _mainLoop.ProcessInput = ProcessInput; #if HACK_CHECK_WINCHANGED - _mainLoop.WinChanged += ChangeWin; + _mainLoop.WinChanged = ChangeWin; #endif } diff --git a/Terminal.Gui/Drawing/Glyphs.cs b/Terminal.Gui/Drawing/Glyphs.cs index fa8160441..6cc4aa5fe 100644 --- a/Terminal.Gui/Drawing/Glyphs.cs +++ b/Terminal.Gui/Drawing/Glyphs.cs @@ -129,6 +129,11 @@ namespace Terminal.Gui { /// public Rune Dot { get; set; } = (Rune)'∙'; + /// + /// Black Circle . Default is (U+025cf) - ●. + /// + public Rune BlackCircle { get; set; } = (Rune)'●'; // Black Circle - ● U+025cf + /// /// Expand (e.g. for . /// diff --git a/Terminal.Gui/Types/Point.cs b/Terminal.Gui/Types/Point.cs index ff4f07fd8..08a824cb3 100644 --- a/Terminal.Gui/Types/Point.cs +++ b/Terminal.Gui/Types/Point.cs @@ -41,7 +41,7 @@ namespace Terminal.Gui /// /// An uninitialized Point Structure. /// - + public static readonly Point Empty; /// diff --git a/Terminal.Gui/Views/Slider.cs b/Terminal.Gui/Views/Slider.cs new file mode 100644 index 000000000..d6d67fc12 --- /dev/null +++ b/Terminal.Gui/Views/Slider.cs @@ -0,0 +1,1712 @@ +using Microsoft.VisualBasic.FileIO; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Unix.Terminal; + +namespace Terminal.Gui; + +/// +/// for events. +/// +public class SliderOptionEventArgs : EventArgs { + /// + /// Gets whether the option is set or not. + /// + public bool IsSet { get; } + + + /// + /// Initializes a new instance of + /// + /// indicates whether the option is set + public SliderOptionEventArgs (bool isSet) + { + IsSet = isSet; + } +} + +/// +/// Represents an option in a . +/// +/// Datatype of the option. +public class SliderOption { + /// + /// Legend of the option. + /// + public string Legend { get; set; } + + /// + /// Abbreviation of the Legend. When the too small to fit . + /// + public Rune LegendAbbr { get; set; } + + /// + /// Custom data of the option. + /// + public T Data { get; set; } + + /// + /// To Raise the event from the Slider. + /// + internal void OnSet () + { + Set?.Invoke (this, new SliderOptionEventArgs (true)); + } + + /// + /// To Raise the event from the Slider. + /// + internal void OnUnSet () + { + UnSet?.Invoke (this, new SliderOptionEventArgs (false)); + } + + /// + /// To Raise the event from the Slider. + /// + internal void OnChanged (bool isSet) + { + Changed?.Invoke (this, new SliderOptionEventArgs (isSet)); + } + + /// + /// Event Raised when this option is set. + /// + public event EventHandler Set; + + /// + /// Event Raised when this option is unset. + /// + public event EventHandler UnSet; + + /// + /// Event fired when the an option has changed. + /// + public event EventHandler Changed; +} + +/// +/// Types +/// +public enum SliderType { + /// + /// + /// ├─┼─┼─┼─┼─█─┼─┼─┼─┼─┼─┼─┤ + /// + /// + Single, + /// + /// + /// ├─┼─█─┼─┼─█─┼─┼─┼─┼─█─┼─┤ + /// + /// + Multiple, + /// + /// + /// ├▒▒▒▒▒▒▒▒▒█─┼─┼─┼─┼─┼─┼─┤ + /// + /// + LeftRange, + /// + /// + /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒▒▒▒▒▒▒┤ + /// + /// + RightRange, + /// + /// + /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒█─┼─┼─┤ + /// + /// + Range +} + +/// +/// Legend Style +/// +public class SliderAttributes { + /// + /// Attribute for when the respective Option is NOT Set. + /// + public Attribute? NormalAttribute { get; set; } + /// + /// Attribute for when the respective Option is Set. + /// + public Attribute? SetAttribute { get; set; } + /// + /// Attribute for the Legends Container. + /// + public Attribute? EmptyAttribute { get; set; } +} + +/// +/// Style +/// +public class SliderStyle { + /// + /// Legend attributes + /// + public SliderAttributes LegendAttributes { get; set; } + /// + /// The glyph and the attribute used for empty spaces on the slider. + /// + public Cell EmptyChar { get; set; } + /// + /// The glyph and the attribute used for each option (tick) on the slider. + /// + public Cell OptionChar { get; set; } + /// + /// The glyph and the attribute used for options (ticks) that are set on the slider. + /// + public Cell SetChar { get; set; } + /// + /// The glyph and the attribute to indicate mouse dragging. + /// + public Cell DragChar { get; set; } + /// + /// The glyph and the attribute used for spaces between options (ticks) on the slider. + /// + public Cell SpaceChar { get; set; } + /// + /// The glyph and the attribute used for filling in ranges on the slider. + /// + public Cell RangeChar { get; set; } + /// + /// The glyph and the attribute used for the start of ranges on the slider. + /// + public Cell StartRangeChar { get; set; } + /// + /// The glyph and the attribute used for the end of ranges on the slider. + /// + public Cell EndRangeChar { get; set; } + + /// + /// Constructs a new instance. + /// + public SliderStyle () + { + LegendAttributes = new SliderAttributes { }; + } +} + +/// +/// All configuration are grouped in this class. +/// +internal class SliderConfiguration { + internal bool _rangeAllowSingle; + internal bool _allowEmpty; + + internal int _mouseClickXOptionThreshold; + + internal bool _autoSize; + + internal int _startSpacing; + internal int _endSpacing; + internal int _innerSpacing; + + internal bool _showSpacing; + internal bool _showLegends; + internal bool _showLegendsAbbr; + + internal SliderType _type = SliderType.Single; + internal Orientation _sliderOrientation = Orientation.Horizontal; + internal Orientation _legendsOrientation = Orientation.Horizontal; +} + +/// +/// for events. +/// +public class SliderEventArgs : EventArgs { + /// + /// Gets/sets whether the option is set or not. + /// + public Dictionary> Options { get; set; } + + /// + /// Gets or sets the index of the option that is focused. + /// + public int Focused { get; set; } + + /// + /// If set to true, the focus operation will be canceled, if applicable. + /// + public bool Cancel { get; set; } + + /// + /// Initializes a new instance of + /// + /// The current options. + /// Index of the option that is focused. -1 if no option has the focus. + public SliderEventArgs (Dictionary> options, int focused = -1) + { + Options = options; + Focused = focused; + Cancel = false; + } +} + +/// +/// for events. +/// +public class OrientationEventArgs : EventArgs { + /// + /// The new orientation. + /// + public Orientation Orientation { get; set; } + + /// + /// If set to true, the orientation change operation will be canceled, if applicable. + /// + public bool Cancel { get; set; } + + /// + /// Constructs a new instance. + /// + /// the new orientation + public OrientationEventArgs (Orientation orientation) + { + Orientation = orientation; + Cancel = false; + } +} +/// +/// Slider control. +/// +public class Slider : Slider { + + /// + /// Initializes a new instance of the class. + /// + public Slider () : base () { } + + /// + /// Initializes a new instance of the class. + /// + /// Initial slider options. + /// Initial slider options. + public Slider (List options, Orientation orientation = Orientation.Horizontal) : base (options, orientation) { } +} + +/// +/// Provides a slider control letting the user navigate from a set of typed options in a linear manner using the keyboard or mouse. +/// +/// +public class Slider : View { + SliderConfiguration _config = new SliderConfiguration (); + SliderStyle _style = new SliderStyle (); + + // Options + List> _options; + // List of the current set options. + List _setOptions = new List (); + + /// + /// The focused option (has the cursor). + /// + public int FocusedOption { get; set; } + + #region Events + + /// + /// Event raised when the slider option/s changed. + /// The dictionary contains: key = option index, value = T + /// + public event EventHandler> OptionsChanged; + + /// + /// Overridable method called when the slider options have changed. Raises the event. + /// + public virtual void OnOptionsChanged () + { + OptionsChanged?.Invoke (this, new SliderEventArgs (GetSetOptionDictionary ())); + SetNeedsDisplay (); + } + + /// + /// Event raised When the option is hovered with the keys or the mouse. + /// + public event EventHandler> OptionFocused; + + int _lastFocusedOption; // for Range type; the most recently focused option. Used to determine shrink direction + + /// + /// Overridable function that fires the event. + /// + /// + /// if the focus change was cancelled. + public virtual bool OnOptionFocused (int newFocusedOption, SliderEventArgs args) + { + if (newFocusedOption > _options.Count - 1 || newFocusedOption < 0) { + return true; + } + OptionFocused?.Invoke (this, args); + if (!args.Cancel) { + _lastFocusedOption = FocusedOption; + FocusedOption = newFocusedOption; + PositionCursor (); + } + return args.Cancel; + } + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + public Slider () : this (new List ()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Initial slider options. + /// Initial slider orientation. + public Slider (List options, Orientation orientation = Orientation.Horizontal) + { + if (options == null) { + SetInitialProperties (null, orientation); + } else { + SetInitialProperties (options.Select (e => { + var legend = e.ToString (); + return new SliderOption { + Data = e, + Legend = legend, + LegendAbbr = (Rune)(legend?.Length > 0 ? legend [0] : ' '), + }; + }).ToList (), orientation); + } + } + + #endregion + + #region Initialize + void SetInitialProperties (List> options, Orientation orientation = Orientation.Horizontal) + { + CanFocus = true; + + this._options = options ?? new List> (); + + _config._sliderOrientation = orientation; + + _config._showLegends = true; + + SetDefaultStyle (); + SetCommands (); + + // When we lose focus of the View(Slider), if we are range selecting we stop it. + Leave += (object s, FocusEventArgs e) => { + //if (_settingRange == true) { + // _settingRange = false; + //} + Driver.SetCursorVisibility (CursorVisibility.Invisible); + }; + + + Enter += (object s, FocusEventArgs e) => { + }; + + LayoutComplete += (s, e) => { + CalcSpacingConfig (); + AdjustBestHeight (); + AdjustBestWidth (); + }; + } + #endregion + + #region Properties + + /// + /// Allow no selection. + /// + public bool AllowEmpty { + get => _config._allowEmpty; + set { + _config._allowEmpty = value; + if (!value && _options.Count > 0 && _setOptions.Count == 0) { + SetOption (0); + } + } + } + + /// + /// If the slider will be sized to fit the available space (the Bounds of the the SuperView). + /// + /// + /// For testing, if there is no SuperView, the slider will be sized based on what is + /// set to. + /// + public override bool AutoSize { + get => _config._autoSize; + set => _config._autoSize = value; + } + + /// + /// Gets or sets the number of rows/columns between + /// + public int InnerSpacing { + + get => _config._innerSpacing; + set { + _config._innerSpacing = value; + CalcSpacingConfig (); + Adjust (); + SetNeedsDisplay (); + SuperView?.SetNeedsLayout (); + } + } + + /// + /// Slider Type. + /// + public SliderType Type { + get => _config._type; + set { + _config._type = value; + + // Todo: Custom logic to preserve options. + _setOptions.Clear (); + + SetNeedsDisplay (); + } + } + + /// + /// Slider Orientation. + /// + public Orientation Orientation { + get => _config._sliderOrientation; + set => OnOrientationChanged (value); + } + + /// + /// Fired when the slider orientation has changed. Can be cancelled by setting to true. + /// + public event EventHandler OrientationChanged; + + /// + /// Called when the slider orientation has changed. Invokes the event. + /// + /// + /// True of the event was cancelled. + public virtual bool OnOrientationChanged (Orientation newOrientation) + { + var args = new OrientationEventArgs (newOrientation); + OrientationChanged?.Invoke (this, args); + if (!args.Cancel) { + _config._sliderOrientation = newOrientation; + SetKeyBindings (); + if (IsInitialized) { + CalcSpacingConfig (); + Adjust (); + SetNeedsDisplay (); + SuperView?.SetNeedsLayout (); + } + } + return args.Cancel; + } + + /// + /// Legends Orientation. + /// + public Orientation LegendsOrientation { + get => _config._legendsOrientation; + set { + _config._legendsOrientation = value; + CalcSpacingConfig (); + Adjust (); + SetNeedsDisplay (); + SuperView?.SetNeedsLayout (); + } + } + + /// + /// Slider styles. + /// + public SliderStyle Style { + get { + // Note(jmperricone): Maybe SliderStyle should be a struct so we return a copy ??? + // Or SetStyle() and ( GetStyle() || Style getter copy ) + return _style; + } + set { + // Note(jmperricone): If the user change a style, he/she must call SetNeedsDisplay(). OK ??? + _style = value; + } + } + + /// + /// Set the slider options. + /// + public List> Options { + get { + // Note(jmperricone): Maybe SliderOption should be a struct so we return a copy ??? + // Events will be preserved ? Need a test. + // Or SetOptions() and ( GetOptions() || Options getter copy ) + return _options; + } + set { + _options = value; + + if (_options == null || _options.Count == 0) { + return; + } + + CalcSpacingConfig (); + Adjust (); + SetNeedsDisplay (); + SuperView?.SetNeedsLayout (); + } + } + + /// + /// Allow range start and end be in the same option, as a single option. + /// + public bool RangeAllowSingle { + get => _config._rangeAllowSingle; + set { + _config._rangeAllowSingle = value; + } + } + + /// + /// Show/Hide spacing before and after the first and last option. + /// + public bool ShowSpacing { + get => _config._showSpacing; + set { + _config._showSpacing = value; + SetNeedsDisplay (); + } + } + + /// + /// Show/Hide the options legends. + /// + public bool ShowLegends { + get => _config._showLegends; + set { + _config._showLegends = value; + Adjust (); + } + } + + /// + /// Causes the specified option to be set and be focused. + /// + public bool SetOption (int optionIndex) + { + // TODO: Handle range type. + // Note: Maybe return false only when optionIndex doesn't exist, otherwise true. + + if (!_setOptions.Contains (optionIndex) && optionIndex >= 0 && optionIndex < _options.Count) { + FocusedOption = optionIndex; + SetFocusedOption (); + return true; + } + return false; + } + + /// + /// Causes the specified option to be un-set and be focused. + /// + public bool UnSetOption (int optionIndex) + { + // TODO: Handle range type. + if ((!AllowEmpty && _setOptions.Count > 2) && _setOptions.Contains (optionIndex)) { + FocusedOption = optionIndex; + SetFocusedOption (); + return true; + } + return false; + } + + /// + /// Get the indexes of the set options. + /// + public List GetSetOptions () + { + // Copy + return _setOptions.OrderBy (e => e).ToList (); + } + + #endregion + + #region Helpers + void MoveAndAdd (int x, int y, Rune rune) + { + Move (x, y); + Driver?.AddRune (rune); + } + + void MoveAndAdd (int x, int y, string str) + { + Move (x, y); + Driver?.AddStr (str); + } + + // TODO: Make configurable via ConfigurationManager + void SetDefaultStyle () + { + switch (_config._sliderOrientation) { + case Orientation.Horizontal: + _style.SpaceChar = new Cell () { Runes = { CM.Glyphs.HLine } }; // '─' + _style.OptionChar = new Cell () { Runes = { CM.Glyphs.BlackCircle } }; // '┼●🗹□⏹' + break; + case Orientation.Vertical: + _style.SpaceChar = new Cell () { Runes = { CM.Glyphs.VLine } }; + _style.OptionChar = new Cell () { Runes = { CM.Glyphs.BlackCircle } }; + break; + } + + // TODO(jmperricone) Wide Vertical ??? + /* + │ + │ + ┼─ 40 + │ + │ + ███ 30 + ▒▒▒ + ▒▒▒ + ▒▒▒ 20 + ▒▒▒ + ▒▒▒ + ███ 10 + │ + │ + ─●─ 0 + */ + + _config._legendsOrientation = _config._sliderOrientation; + _style.EmptyChar = new Cell () { Runes = { new Rune (' ') } }; + _style.SetChar = new Cell () { Runes = { CM.Glyphs.ContinuousMeterSegment } }; // ■ + _style.RangeChar = new Cell () { Runes = { CM.Glyphs.Stipple } }; // ░ ▒ ▓ // Medium shade not blinking on curses. + _style.StartRangeChar = new Cell () { Runes = { CM.Glyphs.ContinuousMeterSegment } }; + _style.EndRangeChar = new Cell () { Runes = { CM.Glyphs.ContinuousMeterSegment } }; + _style.DragChar = new Cell () { Runes = { CM.Glyphs.Diamond } }; + + // TODO: Support left & right (top/bottom) + // First = '├', + // Last = '┤', + } + + /// + /// Calculates the spacing configuration (start, inner, end) as well as turning on/off legend abbreviation + /// if needed. Behaves differently based on and . + /// + internal void CalcSpacingConfig () + { + int size = 0; + + if (_options.Count == 0) { + return; + } + + if (_config._autoSize || !IsInitialized) { + if (IsInitialized && SuperView != null) { + // Calculate the size of the slider based on the size of the SuperView's Bounds. + // TODO: + + } else { + // Use the config values + size = CalcLength (); + return; + } + } else { + // Fit Slider to the actual width and height. + if (_config._sliderOrientation == Orientation.Horizontal) { + size = Bounds.Width; + } else { + size = Bounds.Height; + } + } + + int max_legend; + if (_config._sliderOrientation == _config._legendsOrientation) { + max_legend = _options.Max (e => e.Legend.ToString ().Length); + } else { + max_legend = 1; + } + + var min = (size - max_legend) / (_options.Count - 1); + + string first; + string last; + + if (max_legend >= min) { + if (_config._sliderOrientation == _config._legendsOrientation) { + _config._showLegendsAbbr = true; + } + first = "x"; + last = "x"; + } else { + _config._showLegendsAbbr = false; + first = _options.First ().Legend; + last = _options.Last ().Legend; + } + + // --o-- + // Hello + // Left = He + // Right = lo + var first_left = (first.Length - 1) / 2; // Chars count of the first option to the left. + var last_right = (last.Length) / 2; // Chars count of the last option to the right. + + if (_config._sliderOrientation != _config._legendsOrientation) { + first_left = 0; + last_right = 0; + } + + var width = size - first_left - last_right - 1; + + _config._startSpacing = first_left; + _config._innerSpacing = Math.Max (0, (int)Math.Floor ((double)width / (_options.Count - 1)) - 1); + _config._endSpacing = last_right; + + } + + /// + /// Adjust the height of the Slider to the best value. + /// + public void AdjustBestHeight () + { + // Hack??? Otherwise we can't go back to Dim.Absolute. + LayoutStyle = LayoutStyle.Absolute; + + if (_config._sliderOrientation == Orientation.Horizontal) { + Bounds = new Rect (Bounds.Location, new Size (Bounds.Width, CalcThickness ())); + } else { + Bounds = new Rect (Bounds.Location, new Size (Bounds.Width, CalcLength ())); + } + + LayoutStyle = LayoutStyle.Computed; + } + + /// + /// Adjust the height of the Slider to the best value. (Only works if height is DimAbsolute). + /// + public void AdjustBestWidth () + { + LayoutStyle = LayoutStyle.Absolute; + + if (_config._sliderOrientation == Orientation.Horizontal) { + Bounds = new Rect (Bounds.Location, new Size (CalcLength (), Bounds.Height)); + } else { + Bounds = new Rect (Bounds.Location, new Size (CalcThickness (), Bounds.Height)); + } + + LayoutStyle = LayoutStyle.Computed; + } + + void Adjust () + { + if (Width is Dim.DimAbsolute) { + AdjustBestWidth (); + } + + if (Height is Dim.DimAbsolute) { + AdjustBestHeight (); + } + } + + internal int CalcLength () + { + if (_options.Count == 0) { + return 0; + } + + var length = 0; + length += _config._startSpacing + _config._endSpacing; + length += _options.Count; + length += (_options.Count - 1) * _config._innerSpacing; + return length; + } + + int CalcThickness () + { + var thickness = 1; // Always show the slider. + + if (_config._showLegends) { + if (_config._legendsOrientation != _config._sliderOrientation && _options.Count > 0) { + thickness += _options.Max (s => s.Legend.Length); + } else { + thickness += 1; + } + } + + return thickness; + } + + internal bool TryGetPositionByOption (int option, out (int x, int y) position) + { + position = (-1, -1); + + if (option < 0 || option >= _options.Count ()) { + return false; + } + + var offset = 0; + offset += _config._startSpacing; + offset += option * (_config._innerSpacing + 1); + + if (_config._sliderOrientation == Orientation.Vertical) { + position = (0, offset); + } else { + position = (offset, 0); + } + + return true; + } + + /// + /// Tries to get the option index by the position. + /// + /// + /// + /// + /// + /// + internal bool TryGetOptionByPosition (int x, int y, int threshold, out int option_idx) + { + // Fix(jmperricone): Not working. + option_idx = -1; + if (Orientation == Orientation.Horizontal) { + if (y != 0) { + return false; + } + + for (int xx = (x - threshold); xx < (x + threshold + 1); xx++) { + var cx = xx; + cx -= _config._startSpacing; + + var option = cx / (_config._innerSpacing + 1); + var valid = cx % (_config._innerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options.Count - 1) { + continue; + } + + option_idx = option; + return true; + } + + } else { + if (x != 0) { + return false; + } + + for (int yy = (y - threshold); yy < (y + threshold + 1); yy++) { + var cy = yy; + cy -= _config._startSpacing; + + var option = cy / (_config._innerSpacing + 1); + var valid = cy % (_config._innerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options.Count - 1) { + continue; + } + + option_idx = option; + return true; + } + } + + return false; + } + + #endregion + + #region Cursor and Drawing + + /// + public override void PositionCursor () + { + //base.PositionCursor (); + + if (HasFocus) { + Driver?.SetCursorVisibility (CursorVisibility.Default); + } else { + Driver?.SetCursorVisibility (CursorVisibility.Invisible); + } + if (TryGetPositionByOption (FocusedOption, out (int x, int y) position)) { + if (IsInitialized && Bounds.Contains (position.x, position.y)) { + Move (position.x, position.y); + } + } + } + + /// + public override void OnDrawContent (Rect contentArea) + { + // TODO: make this more surgical to reduce repaint + + var normalScheme = ColorScheme?.Normal ?? Application.Current.ColorScheme.Normal; + + if (this._options == null && this._options.Count > 0) { + return; + } + + // Debug +#if (DEBUG) + Driver.SetAttribute (new Attribute (Color.White, Color.Red)); + for (var y = 0; y < Bounds.Height; y++) { + for (var x = 0; x < Bounds.Width; x++) { + // MoveAndAdd (x, y, '·'); + } + } +#endif + + // Draw Slider + DrawSlider (); + + // Draw Legends. + if (_config._showLegends) { + DrawLegends (); + } + + if (_dragPosition.HasValue && _moveRenderPosition.HasValue) { + AddRune (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, _style.DragChar.Runes [0]); + } + } + + string AlignText (string text, int width, TextAlignment textAlignment) + { + if (text == null) { + return ""; + } + + if (text.Length > width) { + text = text [0..width]; + } + + var w = width - text.Length; + var s1 = new string (' ', w / 2); + var s2 = new string (' ', w % 2); + + // Note: The formatter doesn't handle all of this ??? + switch (textAlignment) { + case TextAlignment.Justified: + + return TextFormatter.Justify (text, width); + case TextAlignment.Left: + return text + s1 + s1 + s2; + case TextAlignment.Centered: + if (text.Length % 2 != 0) { + return s1 + text + s1 + s2; + } else { + return s1 + s2 + text + s1; + } + case TextAlignment.Right: + return s1 + s1 + s2 + text; + default: + return text; + } + } + + void DrawSlider () + { + // TODO: be more surgical on clear + Clear (); + + // Attributes + var normalScheme = ColorScheme?.Normal ?? Application.Current.ColorScheme.Normal; + //var normalScheme = style.LegendStyle.NormalAttribute ?? ColorScheme.Disabled; + var setScheme = _style.SetChar.Attribute ?? ColorScheme.HotNormal;// ColorScheme?.Focus ?? Application.Current.ColorScheme.Focus; + + var isVertical = _config._sliderOrientation == Orientation.Vertical; + var isLegendsVertical = _config._legendsOrientation == Orientation.Vertical; + var isReverse = _config._sliderOrientation != _config._legendsOrientation; + + var x = 0; + var y = 0; + + var isSet = _setOptions.Count > 0; + + // Left Spacing + if (_config._showSpacing && _config._startSpacing > 0) { + + Driver.SetAttribute (isSet && _config._type == SliderType.LeftRange ? _style.RangeChar.Attribute ?? normalScheme : _style.SpaceChar.Attribute ?? normalScheme); + var rune = isSet && _config._type == SliderType.LeftRange ? _style.RangeChar.Runes [0] : _style.SpaceChar.Runes [0]; + + for (var i = 0; i < this._config._startSpacing; i++) { + MoveAndAdd (x, y, rune); + if (isVertical) y++; else x++; + } + } else { + Driver.SetAttribute (_style.EmptyChar.Attribute ?? normalScheme); + // for (int i = 0; i < this.config.StartSpacing + ((this.config.StartSpacing + this.config.EndSpacing) % 2 == 0 ? 1 : 2); i++) { + for (var i = 0; i < this._config._startSpacing; i++) { + MoveAndAdd (x, y, _style.EmptyChar.Runes [0]); + if (isVertical) y++; else x++; + } + } + + // Slider + if (_options.Count > 0) { + for (var i = 0; i < _options.Count; i++) { + + var drawRange = false; + + if (isSet) { + switch (_config._type) { + case SliderType.LeftRange when i <= _setOptions [0]: + drawRange = i < _setOptions [0]; + break; + case SliderType.RightRange when i >= _setOptions [0]: + drawRange = i >= _setOptions [0]; + break; + case SliderType.Range when _setOptions.Count == 1: + drawRange = false; + break; + case SliderType.Range when _setOptions.Count == 2: + if ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0])) { + drawRange = (i >= _setOptions [0] && i < _setOptions [1]) || (i >= _setOptions [1] && i < _setOptions [0]); + + } + break; + default: + // Is Not a Range. + break; + } + } + + // Draw Option + Driver.SetAttribute (isSet && _setOptions.Contains (i) ? _style.SetChar.Attribute ?? setScheme : drawRange ? _style.RangeChar.Attribute ?? setScheme : _style.OptionChar.Attribute ?? normalScheme); + + // Note(jmperricone): Maybe only for curses, windows inverts actual colors, while curses inverts bg with fg. + //if (Application.Driver is CursesDriver) { + // if (FocusedOption == i && HasFocus) { + // Driver.SetAttribute (ColorScheme.Focus); + // } + //} + Rune rune = drawRange ? _style.RangeChar.Runes [0] : _style.OptionChar.Runes [0]; + if (isSet) { + if (_setOptions [0] == i) { + rune = _style.StartRangeChar.Runes [0]; + } else if (_setOptions.Count > 1 && _setOptions [1] == i) { + rune = _style.EndRangeChar.Runes [0]; + } else if (_setOptions.Contains (i)) { + rune = _style.SetChar.Runes [0]; + } + } + MoveAndAdd (x, y, rune); + if (isVertical) y++; else x++; + + // Draw Spacing + if (_config._showSpacing || i < _options.Count - 1) { // Skip if is the Last Spacing. + Driver.SetAttribute (drawRange && isSet ? _style.RangeChar.Attribute ?? setScheme : _style.SpaceChar.Attribute ?? normalScheme); + for (var s = 0; s < _config._innerSpacing; s++) { + MoveAndAdd (x, y, drawRange && isSet ? _style.RangeChar.Runes [0] : _style.SpaceChar.Runes [0]); + if (isVertical) y++; else x++; + } + } + } + } + + var remaining = isVertical ? Bounds.Height - y : Bounds.Width - x; + // Right Spacing + if (_config._showSpacing) { + Driver.SetAttribute (isSet && _config._type == SliderType.RightRange ? _style.RangeChar.Attribute ?? normalScheme : _style.SpaceChar.Attribute ?? normalScheme); + var rune = isSet && _config._type == SliderType.RightRange ? _style.RangeChar.Runes [0] : _style.SpaceChar.Runes [0]; + for (var i = 0; i < remaining; i++) { + MoveAndAdd (x, y, rune); + if (isVertical) y++; else x++; + } + } else { + Driver.SetAttribute (_style.EmptyChar.Attribute ?? normalScheme); + for (var i = 0; i < remaining; i++) { + MoveAndAdd (x, y, _style.EmptyChar.Runes [0]); + if (isVertical) y++; else x++; + } + } + } + + void DrawLegends () + { + // Attributes + var normalScheme = _style.LegendAttributes.NormalAttribute ?? ColorScheme?.Normal ?? ColorScheme.Disabled; + var setScheme = _style.LegendAttributes.SetAttribute ?? ColorScheme?.HotNormal ?? ColorScheme.Normal; + var spaceScheme = normalScheme;// style.LegendStyle.EmptyAttribute ?? normalScheme; + + var isTextVertical = _config._legendsOrientation == Orientation.Vertical; + var isSet = _setOptions.Count > 0; + + var x = 0; + var y = 0; + + Move (x, y); + + if (_config._sliderOrientation == Orientation.Horizontal && _config._legendsOrientation == Orientation.Vertical) { + x += _config._startSpacing; + } + if (_config._sliderOrientation == Orientation.Vertical && _config._legendsOrientation == Orientation.Horizontal) { + y += _config._startSpacing; + } + + if (_config._sliderOrientation == Orientation.Horizontal) { + y += 1; + } else { // Vertical + x += 1; + } + + for (int i = 0; i < _options.Count; i++) { + + bool isOptionSet = false; + + // Check if the Option is Set. + switch (_config._type) { + case SliderType.Single: + case SliderType.Multiple: + if (isSet && _setOptions.Contains (i)) + isOptionSet = true; + break; + case SliderType.LeftRange: + if (isSet && i <= _setOptions [0]) + isOptionSet = true; + break; + case SliderType.RightRange: + if (isSet && i >= _setOptions [0]) + isOptionSet = true; + break; + case SliderType.Range when _setOptions.Count == 1: + if (isSet && i == _setOptions [0]) + isOptionSet = true; + break; + case SliderType.Range: + if (isSet && ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0]))) { + isOptionSet = true; + } + break; + } + + // Text || Abbreviation + string text = string.Empty; + if (_config._showLegendsAbbr) { + text = _options [i].LegendAbbr.ToString () ?? new Rune (_options [i].Legend.First ()).ToString (); + } else { + text = _options [i].Legend; + } + + switch (_config._sliderOrientation) { + case Orientation.Horizontal: + switch (_config._legendsOrientation) { + case Orientation.Horizontal: + text = AlignText (text, _config._innerSpacing + 1, TextAlignment.Centered); + break; + case Orientation.Vertical: + y = 1; + break; + } + break; + case Orientation.Vertical: + switch (_config._legendsOrientation) { + case Orientation.Horizontal: + x = 1; + break; + case Orientation.Vertical: + text = AlignText (text, _config._innerSpacing + 1, TextAlignment.Centered); + break; + } + break; + } + + // Text + var legend_left_spaces_count = text.TakeWhile (e => e == ' ').Count (); + var legend_right_spaces_count = text.Reverse ().TakeWhile (e => e == ' ').Count (); + text = text.Trim (); + + // TODO(jmperricone): Improve the Orientation check. + + // Calculate Start Spacing + if (_config._sliderOrientation == _config._legendsOrientation) { + if (i == 0) { + // The spacing for the slider use the StartSpacing but... + // The spacing for the legends is the StartSpacing MINUS the total chars to the left of the first options. + // ●────●────● + // Hello Bye World + // + // chars_left is 2 for Hello => (5 - 1) / 2 + // + // then the spacing is 2 for the slider but 0 for the legends. + + var chars_left = (text.Length - 1) / 2; + legend_left_spaces_count = _config._startSpacing - chars_left; + } + + // Option Left Spacing + if (isTextVertical) y += legend_left_spaces_count; + else x += legend_left_spaces_count; + //Move (x, y); + } + + // Legend + Driver.SetAttribute (isOptionSet ? setScheme : normalScheme); + foreach (var c in text.EnumerateRunes ()) { + MoveAndAdd (x, y, c); + //Driver.AddRune (c); + if (isTextVertical) y += 1; + else x += 1; + } + + // Calculate End Spacing + if (i == _options.Count () - 1) { + // See Start Spacing explanation. + var chars_right = text.Length / 2; + legend_right_spaces_count = _config._endSpacing - chars_right; + } + + // Option Right Spacing of Option + Driver.SetAttribute (spaceScheme); + if (isTextVertical) y += legend_right_spaces_count; + else x += legend_right_spaces_count; + + if (_config._sliderOrientation == Orientation.Horizontal && _config._legendsOrientation == Orientation.Vertical) { + x += _config._innerSpacing + 1; + } else if (_config._sliderOrientation == Orientation.Vertical && _config._legendsOrientation == Orientation.Horizontal) { + y += _config._innerSpacing + 1; + } + } + } + + #endregion + + #region Keys and Mouse + + // Mouse coordinates of current drag + Point? _dragPosition; + // Coordinates of where the "move cursor" is drawn (in OnDrawContent) + Point? _moveRenderPosition; + + /// + public override bool MouseEvent (MouseEvent mouseEvent) + { + // Note(jmperricone): Maybe we click to focus the cursor, and on next click we set the option. + // That will makes OptionFocused Event more relevant. + // (tig: I don't think so. Maybe an option if someone really wants it, but for now that + // adss to much friction to UI. + // TODO(jmperricone): Make Range Type work with mouse. + + if (!(mouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked) || + mouseEvent.Flags.HasFlag (MouseFlags.Button1Pressed) || + mouseEvent.Flags.HasFlag (MouseFlags.ReportMousePosition) || + mouseEvent.Flags.HasFlag (MouseFlags.Button1Released))) { + return false; + } + + Point ClampMovePosition (Point position) + { + int Clamp (int value, int min, int max) => Math.Max (min, Math.Min (max, value)); + + if (Orientation == Orientation.Horizontal) { + var left = _config._startSpacing; + var width = _options.Count + (_options.Count - 1) * _config._innerSpacing; + var right = (left + width - 1); + var clampedX = Clamp (position.X, left, right); + position = new Point (clampedX, 0); + } else { + var top = _config._startSpacing; + var height = _options.Count + (_options.Count - 1) * _config._innerSpacing; + var bottom = (top + height - 1); + var clampedY = Clamp (position.Y, top, bottom); + position = new Point (0, clampedY); + } + return position; + } + + SetFocus (); + + if (!_dragPosition.HasValue && (mouseEvent.Flags.HasFlag (MouseFlags.Button1Pressed))) { + + if (mouseEvent.Flags.HasFlag (MouseFlags.ReportMousePosition)) { + _dragPosition = new Point (mouseEvent.X, mouseEvent.Y); + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition); + Application.GrabMouse (this); + } + SetNeedsDisplay (); + return true; + } + + if (_dragPosition.HasValue && mouseEvent.Flags.HasFlag (MouseFlags.ReportMousePosition) && mouseEvent.Flags.HasFlag (MouseFlags.Button1Pressed)) { + + // Continue Drag + _dragPosition = new Point (mouseEvent.X, mouseEvent.Y); + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition); + + var success = false; + var option = 0; + // how far has user dragged from original location? + if (Orientation == Orientation.Horizontal) { + success = TryGetOptionByPosition (mouseEvent.X, 0, Math.Max (0, _config._innerSpacing / 2), out option); + } else { + success = TryGetOptionByPosition (0, mouseEvent.Y, Math.Max (0, _config._innerSpacing / 2), out option); + } + if (!_config._allowEmpty && success) { + if (!OnOptionFocused (option, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + SetFocusedOption (); + } + } + + SetNeedsDisplay (); + return true; + } + + if ((_dragPosition.HasValue && mouseEvent.Flags.HasFlag (MouseFlags.Button1Released)) || mouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) { + + // End Drag + Application.UngrabMouse (); + _dragPosition = null; + _moveRenderPosition = null; + + // TODO: Add func to calc distance between options to use as the MouseClickXOptionThreshold + var success = false; + var option = 0; + if (Orientation == Orientation.Horizontal) { + success = TryGetOptionByPosition (mouseEvent.X, 0, Math.Max (0, _config._innerSpacing / 2), out option); + } else { + success = TryGetOptionByPosition (0, mouseEvent.Y, Math.Max (0, _config._innerSpacing / 2), out option); + } + if (success) { + if (!OnOptionFocused (option, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + SetFocusedOption (); + } + } + + SetNeedsDisplay (); + return true; + } + return false; + } + + void SetCommands () + { + AddCommand (Command.Right, () => MovePlus ()); + AddCommand (Command.LineDown, () => MovePlus ()); + AddCommand (Command.Left, () => MoveMinus ()); + AddCommand (Command.LineUp, () => MoveMinus ()); + AddCommand (Command.LeftHome, () => MoveStart ()); + AddCommand (Command.RightEnd, () => MoveEnd ()); + AddCommand (Command.RightExtend, () => ExtendPlus ()); + AddCommand (Command.LeftExtend, () => ExtendMinus ()); + AddCommand (Command.Accept, () => Set ()); + + SetKeyBindings (); + } + + // This is called during initialization and anytime orientation changes + void SetKeyBindings () + { + if (_config._sliderOrientation == Orientation.Horizontal) { + AddKeyBinding (Key.CursorRight, Command.Right); + ClearKeyBinding (Key.CursorDown); + AddKeyBinding (Key.CursorLeft, Command.Left); + ClearKeyBinding (Key.CursorUp); + + AddKeyBinding (Key.CursorRight | Key.CtrlMask, Command.RightExtend); + ClearKeyBinding (Key.CursorDown | Key.CtrlMask); + AddKeyBinding (Key.CursorLeft | Key.CtrlMask, Command.LeftExtend); + ClearKeyBinding (Key.CursorUp | Key.CtrlMask); + } else { + ClearKeyBinding (Key.CursorRight); + AddKeyBinding (Key.CursorDown, Command.LineDown); + ClearKeyBinding (Key.CursorLeft); + AddKeyBinding (Key.CursorUp, Command.LineUp); + + ClearKeyBinding (Key.CursorRight | Key.CtrlMask); + AddKeyBinding (Key.CursorDown | Key.CtrlMask, Command.RightExtend); + ClearKeyBinding (Key.CursorLeft | Key.CtrlMask); + AddKeyBinding (Key.CursorUp | Key.CtrlMask, Command.LeftExtend); + + } + AddKeyBinding (Key.Home, Command.LeftHome); + AddKeyBinding (Key.End, Command.RightEnd); + AddKeyBinding (Key.Enter, Command.Accept); + AddKeyBinding (Key.Space, Command.Accept); + + } + + /// + public override bool ProcessKey (KeyEvent keyEvent) + { + if (!CanFocus || !HasFocus) { + return base.ProcessKey (keyEvent); + } + + var result = InvokeKeybindings (keyEvent); + if (result != null) { + return (bool)result; + } + return base.ProcessKey (keyEvent); + } + + Dictionary> GetSetOptionDictionary () => _setOptions.ToDictionary (e => e, e => _options [e]); + + void SetFocusedOption () + { + switch (_config._type) { + case SliderType.Single: + case SliderType.LeftRange: + case SliderType.RightRange: + + if (_setOptions.Count == 1) { + var prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) { + break; + } + + _setOptions.Clear (); + _options [FocusedOption].OnUnSet (); + + if (FocusedOption != prev) { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + } else { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + + // Raise slider changed event. + OnOptionsChanged (); + + break; + case SliderType.Multiple: + if (_setOptions.Contains (FocusedOption)) { + if (!_config._allowEmpty && _setOptions.Count () == 1) { + break; + } + _setOptions.Remove (FocusedOption); + _options [FocusedOption].OnUnSet (); + } else { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + OnOptionsChanged (); + break; + + case SliderType.Range: + if (_config._rangeAllowSingle) { + if (_setOptions.Count == 1) { + var prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) { + break; + } + if (FocusedOption == prev) { + // un-set + _setOptions.Clear (); + _options [FocusedOption].OnUnSet (); + } else { + _setOptions [0] = FocusedOption; + _setOptions.Add (prev); + _setOptions.Sort (); + _options [FocusedOption].OnSet (); + } + } else if (_setOptions.Count == 0) { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } else { + // Extend/Shrink + if (FocusedOption < _setOptions [0]) { + // extend left + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } else if (FocusedOption > _setOptions [1]) { + // extend right + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1]) { + if (FocusedOption < _lastFocusedOption) { + // shrink to the left + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + + } else if (FocusedOption > _lastFocusedOption) { + // shrink to the right + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + if (_setOptions.Count > 1 && _setOptions [0] == _setOptions [1]) { + _setOptions.Clear (); + _setOptions.Add (FocusedOption); + } + } + } + } else { + if (_setOptions.Count == 1) { + var prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) { + break; + } + _setOptions [0] = FocusedOption; + _setOptions.Add (prev); + _setOptions.Sort (); + _options [FocusedOption].OnSet (); + } else if (_setOptions.Count == 0) { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + var next = FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption - 1; + _setOptions.Add (next); + _options [next].OnSet (); + } else { + // Extend/Shrink + if (FocusedOption < _setOptions [0]) { + // extend left + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } else if (FocusedOption > _setOptions [1]) { + // extend right + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1] && (_setOptions [1] - _setOptions [0] > 1)) { + if (FocusedOption < _lastFocusedOption) { + // shrink to the left + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + + } else if (FocusedOption > _lastFocusedOption) { + // shrink to the right + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + } + //if (_setOptions.Count > 1 && _setOptions [0] == _setOptions [1]) { + // SetFocusedOption (); + //} + } + } + + // Raise Slider Option Changed Event. + OnOptionsChanged (); + + break; + default: + throw new ArgumentOutOfRangeException (_config._type.ToString ()); + } + } + + internal bool ExtendPlus () + { + var next = FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; + if (next != FocusedOption && !OnOptionFocused (next, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + SetFocusedOption (); + } + return true; + + //// TODO: Support RangeMultiple + //if (_setOptions.Contains (FocusedOption)) { + // var next = FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; + // if (!_setOptions.Contains (next)) { + // if (_config._type == SliderType.Range) { + // if (_setOptions.Count == 1) { + // if (!OnOptionFocused (next, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + // _setOptions.Add (FocusedOption); + // _setOptions.Sort (); // Range Type + // OnOptionsChanged (); + // } + // } else if (_setOptions.Count == 2) { + // if (!OnOptionFocused (next, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + // _setOptions [1] = FocusedOption; + // _setOptions.Sort (); // Range Type + // OnOptionsChanged (); + // } + // } + // } else { + // _setOptions.Remove (FocusedOption); + // // Note(jmperricone): We are setting the option here, do we send the OptionFocused Event too ? + + + // if (!OnOptionFocused (next, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + // _setOptions.Add (FocusedOption); + // _setOptions.Sort (); // Range Type + // OnOptionsChanged (); + // } + // } + // } else { + // if (_config._type == SliderType.Range) { + // if (!OnOptionFocused (next, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + // _setOptions.Clear(); + // _setOptions.Add (FocusedOption); + // OnOptionsChanged (); + // } + // } else if (/*_settingRange == true ||*/ !AllowEmpty) { + // SetFocusedOption (); + // } + // } + //} + //return true; + } + + internal bool ExtendMinus () + { + var prev = FocusedOption > 0 ? FocusedOption - 1 : FocusedOption; + if (prev != FocusedOption && !OnOptionFocused (prev, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + SetFocusedOption (); + } + return true; + } + + internal bool Set () + { + SetFocusedOption (); + return true; + } + + internal bool MovePlus () + { + var cancelled = OnOptionFocused (FocusedOption + 1, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption)); + if (cancelled) { + return false; + } + + if (!AllowEmpty) { + SetFocusedOption (); + } + return true; + } + + internal bool MoveMinus () + { + var cancelled = OnOptionFocused (FocusedOption - 1, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption)); + if (cancelled) { + return false; + } + + if (!AllowEmpty) { + SetFocusedOption (); + } + return true; + } + + internal bool MoveStart () + { + if (OnOptionFocused (0, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + return false; + } + + if (!AllowEmpty) { + SetFocusedOption (); + } + return true; + } + + internal bool MoveEnd () + { + if (OnOptionFocused (_options.Count - 1, new SliderEventArgs (GetSetOptionDictionary (), FocusedOption))) { + return false; + } + + if (!AllowEmpty) { + SetFocusedOption (); + } + return true; + } + #endregion +} diff --git a/UICatalog/Properties/launchSettings.json b/UICatalog/Properties/launchSettings.json index 2b666bdf8..6bc8e3150 100644 --- a/UICatalog/Properties/launchSettings.json +++ b/UICatalog/Properties/launchSettings.json @@ -22,6 +22,10 @@ "commandLineArgs": "dotnet UICatalog.dll -usc", "distributionName": "" }, + "Sliders": { + "commandName": "Project", + "commandLineArgs": "Sliders" + }, "Wizards": { "commandName": "Project", "commandLineArgs": "Wizards" diff --git a/UICatalog/Scenarios/Sliders.cs b/UICatalog/Scenarios/Sliders.cs new file mode 100644 index 000000000..e9590401a --- /dev/null +++ b/UICatalog/Scenarios/Sliders.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Terminal.Gui; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata (Name: "Sliders", Description: "Demonstrates the Slider view.")] +[ScenarioCategory ("Controls")] +public class Sliders : Scenario { + public override void Setup () + { + MakeSliders (Win, new List { 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000 }); + + #region configView + + var configView = new FrameView { + Title = "Configuration", + X = Pos.Percent (50), + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (), + ColorScheme = Colors.Dialog + }; + + Win.Add (configView); + + #region Config Slider + + var slider = new Slider () { + Title = "Options", + X = Pos.Center (), + Y = 0, + Type = SliderType.Multiple, + Width = Dim.Fill (), + AllowEmpty = true, + BorderStyle = LineStyle.Single + }; + + slider.Style.SetChar.Attribute = new Terminal.Gui.Attribute (Color.BrightGreen, Color.Black); + slider.Style.LegendAttributes.SetAttribute = new Terminal.Gui.Attribute (Color.Green, Color.Black); + + slider.Options = new List> { + new SliderOption{ + Legend="Legends" + }, + new SliderOption{ + Legend="RangeAllowSingle" + }, + new SliderOption{ + Legend="Spacing" + } + }; + + configView.Add (slider); + + slider.OptionsChanged += (sender, e) => { + foreach (var s in Win.Subviews.OfType ()) { + if (e.Options.ContainsKey (0)) + s.ShowLegends = true; + else + s.ShowLegends = false; + + if (e.Options.ContainsKey (1)) + s.RangeAllowSingle = true; + else + s.RangeAllowSingle = false; + + if (e.Options.ContainsKey (2)) + s.ShowSpacing = true; + else + s.ShowSpacing = false; + } + Win.LayoutSubviews (); + }; + slider.SetOption (0); + slider.SetOption (1); + + #endregion + + #region InnerSpacing Input + // var innerspacing_slider = new Slider ("Innerspacing", new List { "Auto", "0", "1", "2", "3", "4", "5" }) { + // X = Pos.Center (), + // Y = Pos.Bottom (slider) + 1 + // }; + + // innerspacing_slider.SetOption (0); + + // configView.Add (innerspacing_slider); + + // innerspacing_slider.OptionsChanged += (options) => { + // foreach (var s in leftView.Subviews.OfType () ()) { + // if (options.ContainsKey (0)) { } + // //s.la = S.SliderLayout.Auto; + // else { + // s.InnerSpacing = options.Keys.First () - 1; + // } + // } + // }; + #endregion + + #region Slider Orientation Slider + + var slider_orientation_slider = new Slider (new List { "Horizontal", "Vertical" }) { + Title = "Slider Orientation", + X = 0, + Y = Pos.Bottom (slider) + 1, + Width = Dim.Fill (), + BorderStyle = LineStyle.Single + }; + + slider_orientation_slider.SetOption (0); + + configView.Add (slider_orientation_slider); + + slider_orientation_slider.OptionsChanged += (sender, e) => { + + View prev = null; + foreach (var s in Win.Subviews.OfType ()) { + + if (e.Options.ContainsKey (0)) { + s.Orientation = Orientation.Horizontal; + + s.AdjustBestHeight (); + s.Width = Dim.Percent (50); + + s.Style.SpaceChar = new Cell () { Runes = { CM.Glyphs.HLine } }; + + if (prev == null) { + s.LayoutStyle = LayoutStyle.Absolute; + s.Y = 0; + s.LayoutStyle = LayoutStyle.Computed; + } else { + s.Y = Pos.Bottom (prev) + 1; + } + s.X = 0; + prev = s; + + } else if (e.Options.ContainsKey (1)) { + s.Orientation = Orientation.Vertical; + + s.AdjustBestWidth (); + s.Height = Dim.Fill (); + + s.Style.SpaceChar = new Cell () { Runes = { CM.Glyphs.VLine } }; + + + if (prev == null) { + s.LayoutStyle = LayoutStyle.Absolute; + s.X = 0; + s.LayoutStyle = LayoutStyle.Computed; + } else { + s.X = Pos.Right (prev) + 2; + } + s.Y = 0; + prev = s; + } + } + Win.LayoutSubviews (); + }; + + #endregion + + #region Legends Orientation Slider + + var legends_orientation_slider = new Slider (new List { "Horizontal", "Vertical" }) { + Title = "Legends Orientation", + X = Pos.Center (), + Y = Pos.Bottom (slider_orientation_slider) + 1, + Width = Dim.Fill (), + BorderStyle = LineStyle.Single + }; + + legends_orientation_slider.SetOption (0); + + configView.Add (legends_orientation_slider); + + legends_orientation_slider.OptionsChanged += (sender, e) => { + foreach (var s in Win.Subviews.OfType ()) { + if (e.Options.ContainsKey (0)) + s.LegendsOrientation = Orientation.Horizontal; + else if (e.Options.ContainsKey (1)) + s.LegendsOrientation = Orientation.Vertical; + } + Win.LayoutSubviews (); + }; + + #endregion + + #region Color Slider + + var sliderColor = new Slider<(Color, Color)> () { + Title = "Color", + X = Pos.Center (), + Y = Pos.Bottom (legends_orientation_slider) + 1, + Type = SliderType.Single, + Width = Dim.Fill (), + BorderStyle = LineStyle.Single, + AllowEmpty = false + }; + + sliderColor.Style.SetChar.Attribute = new Terminal.Gui.Attribute (Color.BrightGreen, Color.Black); + sliderColor.Style.LegendAttributes.SetAttribute = new Terminal.Gui.Attribute (Color.Green, Color.Blue); + + sliderColor.LegendsOrientation = Orientation.Vertical; + var colorOptions = new List> (); + foreach (var colorIndex in Enum.GetValues ()) { + var colorName = colorIndex.ToString (); + colorOptions.Add (new SliderOption<(Color, Color)> { + Data = (new Color((ColorName)colorIndex), Win.GetNormalColor ().Background), + Legend = colorName, + LegendAbbr = (Rune)colorName [0], + }); + } + sliderColor.Options = colorOptions; + + configView.Add (sliderColor); + + sliderColor.OptionsChanged += (sender, e) => { + if (e.Options.Count != 0) { + var data = e.Options.First ().Value.Data; + + foreach (var s in Win.Subviews.OfType ()) { + s.Style.OptionChar.Attribute = new Attribute (data.Item1, data.Item2); + s.Style.SetChar.Attribute = new Attribute (data.Item1, data.Item2); + s.Style.LegendAttributes.SetAttribute = new Attribute (data.Item1, Win.GetNormalColor ().Background); + s.Style.RangeChar.Attribute = new Attribute (data.Item1, Win.GetNormalColor ().Background); + s.Style.SpaceChar.Attribute = new Attribute (data.Item1, Win.GetNormalColor ().Background); + s.Style.LegendAttributes.NormalAttribute = new Attribute (data.Item1, Win.GetNormalColor ().Background); + // Here we can not call SetNeedDisplay(), because the OptionsChanged was triggered by Key Pressing, + // that internaly calls SetNeedDisplay. + } + } else { + foreach (var s in Win.Subviews.OfType ()) { + s.Style.SetChar.Attribute = null; + s.Style.LegendAttributes.SetAttribute = null; + s.Style.RangeChar.Attribute = null; + } + } + }; + + // Set option after Eventhandler def, so it updates the sliders color. + // sliderColor.SetOption (2); + + #endregion + + #endregion + + Win.FocusFirst (); + + } + + public void MakeSliders (View v, List options) + { + var types = Enum.GetValues (typeof (SliderType)).Cast ().ToList (); + + Slider prev = null; + + foreach (var type in types) { + var view = new Slider (options, Orientation.Horizontal) { + Title = type.ToString (), + X = 0, + //X = Pos.Right (view) + 1, + Y = prev == null ? 0 : Pos.Bottom (prev), + //Y = Pos.Center (), + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + Type = type, + LegendsOrientation = Orientation.Horizontal, + AllowEmpty = true, + }; + v.Add (view); + prev = view; + }; + + var singleOptions = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 }; + + var single = new Slider (singleOptions, Orientation.Horizontal) { + Title = "Actual slider", + X = 0, + //X = Pos.Right (view) + 1, + Y = prev == null ? 0 : Pos.Bottom (prev), + //Y = Pos.Center (), + Type = SliderType.Single, + //BorderStyle = LineStyle.Single, + LegendsOrientation = Orientation.Horizontal, + Width = Dim.Percent (50), + AllowEmpty = false, + //ShowSpacing = true + }; + + single.LayoutStarted += (s, e) => { + if (single.Orientation == Orientation.Horizontal) { + single.Style.SpaceChar = new Cell () { Runes = { CM.Glyphs.HLine } }; + single.Style.OptionChar = new Cell () { Runes = { CM.Glyphs.HLine } }; + } else { + single.Style.SpaceChar = new Cell () { Runes = { CM.Glyphs.VLine } }; + single.Style.OptionChar = new Cell () { Runes = { CM.Glyphs.VLine } }; + } + }; + single.Style.SetChar = new Cell () { Runes = { CM.Glyphs.ContinuousMeterSegment } }; + single.Style.DragChar = new Cell () { Runes = { CM.Glyphs.ContinuousMeterSegment } }; + + v.Add (single); + + var label = new Label () { + X = 0, + Y = Pos.Bottom (single), + Height = 1, + Width = Dim.Width (single), + Text = $"{single.GetSetOptions ().FirstOrDefault ()}" + }; + single.OptionsChanged += (s, e) => { + label.Text = $"{e.Options.FirstOrDefault ().Key}"; + }; + + v.Add (label); + } +} diff --git a/UnitTests/UICatalog/ScenarioTests.cs b/UnitTests/UICatalog/ScenarioTests.cs index 0569a2757..1edd6ece5 100644 --- a/UnitTests/UICatalog/ScenarioTests.cs +++ b/UnitTests/UICatalog/ScenarioTests.cs @@ -74,16 +74,17 @@ namespace UICatalog.Tests { // BUGBUG: (#2474) For some reason ReadKey is not returning the QuitKey for some Scenarios // by adding this Space it seems to work. // See #2474 for why this is commented out - //Assert.Equal (Application.QuitKey, args.KeyEvent.Key); - args.Handled = false; + Assert.Equal (Application.QuitKey, args.KeyEvent.Key); }; uint abortTime = 500; // If the scenario doesn't close within 500ms, this will force it to quit Func forceCloseCallback = (MainLoop loop) => { - Application.RequestStop (); - // See #2474 for why this is commented out - //Assert.Fail ($"'{scenario.GetName ()}' failed to Quit with {Application.QuitKey} after {abortTime}ms. Force quit."); + if (Application.Top.Running && FakeConsole.MockKeyPresses.Count == 0) { + Application.RequestStop (); + // See #2474 for why this is commented out + Assert.Fail ($"'{scenario.GetName ()}' failed to Quit with {Application.QuitKey} after {abortTime}ms. Force quit."); + } return false; }; //output.WriteLine ($" Add timeout to force quit after {abortTime}ms"); @@ -91,9 +92,9 @@ namespace UICatalog.Tests { Application.Iteration += () => { //output.WriteLine ($" iteration {++iterations}"); - if (FakeConsole.MockKeyPresses.Count == 0) { + if (Application.Top.Running && FakeConsole.MockKeyPresses.Count == 0) { Application.RequestStop (); - //Assert.Fail ($"'{scenario.GetName ()}' failed to Quit with {Application.QuitKey}. Force quit."); + Assert.Fail ($"'{scenario.GetName ()}' failed to Quit with {Application.QuitKey}. Force quit."); } }; diff --git a/UnitTests/Views/ColorPickerTests.cs b/UnitTests/Views/ColorPickerTests.cs index 16ebb8331..f8e365d0c 100644 --- a/UnitTests/Views/ColorPickerTests.cs +++ b/UnitTests/Views/ColorPickerTests.cs @@ -1,4 +1,5 @@ -using System; +using Terminal.Gui; +using System; using System.Collections.Generic; using System.Linq; using System.Text; diff --git a/UnitTests/Views/SliderTests.cs b/UnitTests/Views/SliderTests.cs new file mode 100644 index 000000000..ba634ad80 --- /dev/null +++ b/UnitTests/Views/SliderTests.cs @@ -0,0 +1,420 @@ +using Xunit; +using Terminal.Gui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Terminal.Gui.ViewsTests; + +public class SliderOptionTests { + [Fact] + public void OnSet_Should_Raise_SetEvent () + { + // Arrange + var sliderOption = new SliderOption (); + var eventRaised = false; + sliderOption.Set += (sender, args) => eventRaised = true; + + // Act + sliderOption.OnSet (); + + // Assert + Assert.True (eventRaised); + } + + [Fact] + public void OnUnSet_Should_Raise_UnSetEvent () + { + // Arrange + var sliderOption = new SliderOption (); + var eventRaised = false; + sliderOption.UnSet += (sender, args) => eventRaised = true; + + // Act + sliderOption.OnUnSet (); + + // Assert + Assert.True (eventRaised); + } + + [Fact] + public void OnChanged_Should_Raise_ChangedEvent () + { + // Arrange + var sliderOption = new SliderOption (); + var eventRaised = false; + sliderOption.Changed += (sender, args) => eventRaised = true; + + // Act + sliderOption.OnChanged (true); + + // Assert + Assert.True (eventRaised); + } +} + +public class SliderEventArgsTests { + [Fact] + public void Constructor_Sets_Options () + { + // Arrange + var options = new Dictionary> (); + + // Act + var sliderEventArgs = new SliderEventArgs (options); + + // Assert + Assert.Equal (options, sliderEventArgs.Options); + } + + [Fact] + public void Constructor_Sets_Focused () + { + // Arrange + var options = new Dictionary> (); + var focused = 42; + + // Act + var sliderEventArgs = new SliderEventArgs (options, focused); + + // Assert + Assert.Equal (focused, sliderEventArgs.Focused); + } + + [Fact] + public void Constructor_Sets_Cancel_Default_To_False () + { + // Arrange + var options = new Dictionary> (); + var focused = 42; + + // Act + var sliderEventArgs = new SliderEventArgs (options, focused); + + // Assert + Assert.False (sliderEventArgs.Cancel); + } +} + + +public class SliderTests { + [Fact] + public void Constructor_Default () + { + // Arrange & Act + var slider = new Slider (); + + // Assert + Assert.NotNull (slider); + Assert.NotNull (slider.Options); + Assert.Empty (slider.Options); + Assert.Equal (Orientation.Horizontal, slider.Orientation); + Assert.False (slider.AllowEmpty); + Assert.True (slider.ShowLegends); + Assert.False (slider.ShowSpacing); + Assert.Equal (SliderType.Single, slider.Type); + Assert.Equal (0, slider.InnerSpacing); + Assert.False (slider.AutoSize); + Assert.Equal (0, slider.FocusedOption); + } + + [Fact] + public void Constructor_With_Options () + { + // Arrange + var options = new List { 1, 2, 3 }; + + // Act + var slider = new Slider (options); + + // Assert + Assert.NotNull (slider); + Assert.NotNull (slider.Options); + Assert.Equal (options.Count, slider.Options.Count); + } + + [Fact] + public void OnOptionsChanged_Event_Raised () + { + // Arrange + var slider = new Slider (); + bool eventRaised = false; + slider.OptionsChanged += (sender, args) => eventRaised = true; + + // Act + slider.OnOptionsChanged (); + + // Assert + Assert.True (eventRaised); + } + + [Fact] + public void OnOptionFocused_Event_Raised () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3 }); + bool eventRaised = false; + slider.OptionFocused += (sender, args) => eventRaised = true; + int newFocusedOption = 1; + var args = new SliderEventArgs (new Dictionary> (), newFocusedOption); + + // Act + slider.OnOptionFocused (newFocusedOption, args); + + // Assert + Assert.True (eventRaised); + } + + [Fact] + public void OnOptionFocused_Event_Cancelled () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3 }); + bool eventRaised = false; + bool cancel = false; + slider.OptionFocused += (sender, args) => eventRaised = true; + int newFocusedOption = 1; + + // Create args with cancel set to false + cancel = false; + var args = new SliderEventArgs (new Dictionary> (), newFocusedOption) { + Cancel = cancel + }; + Assert.Equal (0, slider.FocusedOption); + + // Act + slider.OnOptionFocused (newFocusedOption, args); + + // Assert + Assert.True (eventRaised); // Event should be raised + Assert.Equal (newFocusedOption, slider.FocusedOption); // Focused option should change + + // Create args with cancel set to true + cancel = true; + args = new SliderEventArgs (new Dictionary> (), newFocusedOption) { + Cancel = cancel + }; + + // Act + slider.OnOptionFocused (2, args); + + // Assert + Assert.True (eventRaised); // Event should be raised + Assert.Equal (newFocusedOption, slider.FocusedOption); // Focused option should not change + } + + [Theory] + [InlineData (0, 0, 0)] + [InlineData (1, 3, 0)] + [InlineData (3, 9, 0)] + public void TryGetPositionByOption_ValidOptionHorizontal_Success (int option, int expectedX, int expectedY) + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.AutoSize = true; // Set auto size to true to enable testing + slider.InnerSpacing = 2; + // 0123456789 + // 1--2--3--4 + + // Act + bool result = slider.TryGetPositionByOption (option, out var position); + + // Assert + Assert.True (result); + Assert.Equal (expectedX, position.x); + Assert.Equal (expectedY, position.y); + } + + [Theory] + [InlineData (0, 0, 0)] + [InlineData (1, 0, 3)] + [InlineData (3, 0, 9)] + public void TryGetPositionByOption_ValidOptionVertical_Success (int option, int expectedX, int expectedY) + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.Orientation = Orientation.Vertical; + slider.AutoSize = true; // Set auto size to true to enable testing + slider.InnerSpacing = 2; + + // Act + bool result = slider.TryGetPositionByOption (option, out var position); + + // Assert + Assert.True (result); + Assert.Equal (expectedX, position.x); + Assert.Equal (expectedY, position.y); + } + + [Fact] + public void TryGetPositionByOption_InvalidOption_Failure () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3 }); + int option = -1; + var expectedPosition = (-1, -1); + + // Act + bool result = slider.TryGetPositionByOption (option, out var position); + + // Assert + Assert.False (result); + Assert.Equal (expectedPosition, position); + } + + [Theory] + [InlineData (0, 0, 0, 1)] + [InlineData (3, 0, 0, 2)] + [InlineData (9, 0, 0, 4)] + [InlineData (0, 0, 1, 1)] + [InlineData (3, 0, 1, 2)] + [InlineData (9, 0, 1, 4)] + public void TryGetOptionByPosition_ValidPositionHorizontal_Success (int x, int y, int threshold, int expectedData) + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.AutoSize = true; // Set auto size to true to enable testing + slider.InnerSpacing = 2; + // 0123456789 + // 1--2--3--4 + + // Arrange + + // Act + bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); + + // Assert + Assert.True (result); + Assert.Equal (expectedData, slider.Options [option].Data); + } + + [Theory] + [InlineData (0, 0, 0, 1)] + [InlineData (0, 3, 0, 2)] + [InlineData (0, 9, 0, 4)] + [InlineData (0, 0, 1, 1)] + [InlineData (0, 3, 1, 2)] + [InlineData (0, 9, 1, 4)] + public void TryGetOptionByPosition_ValidPositionVertical_Success (int x, int y, int threshold, int expectedData) + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.Orientation = Orientation.Vertical; + slider.AutoSize = true; // Set auto size to true to enable testing + slider.InnerSpacing = 2; + // 0 1 + // 1 | + // 2 | + // 3 2 + // 4 | + // 5 | + // 6 3 + // 7 | + // 8 | + // 9 4 + slider.CalcSpacingConfig (); + + // Arrange + + // Act + bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); + + // Assert + Assert.True (result); + Assert.Equal (expectedData, slider.Options [option].Data); + } + + + [Fact] + public void TryGetOptionByPosition_InvalidPosition_Failure () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3 }); + int x = 10; + int y = 10; + int threshold = 2; + int expectedOption = -1; + + // Act + bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); + + // Assert + Assert.False (result); + Assert.Equal (expectedOption, option); + } + + [Fact] + public void MovePlus_Should_MoveFocusRight_When_OptionIsAvailable () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.AutoSize = true; + + // Act + bool result = slider.MovePlus (); + + // Assert + Assert.True (result); + Assert.Equal (1, slider.FocusedOption); + } + + [Fact] + public void MovePlus_Should_NotMoveFocusRight_When_AtEnd () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.AutoSize = true; + slider.FocusedOption = 3; + + // Act + bool result = slider.MovePlus (); + + // Assert + Assert.False (result); + Assert.Equal (3, slider.FocusedOption); + } + + // Add similar tests for other methods like MoveMinus, MoveStart, MoveEnd, Set, etc. + + [Fact] + public void Set_Should_SetFocusedOption () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }); + slider.AutoSize = true; + + // Act + slider.FocusedOption = 2; + bool result = slider.Set (); + + // Assert + Assert.True (result); + Assert.Equal (2, slider.FocusedOption); + Assert.Single (slider.GetSetOptions ()); + } + + [Fact] + public void Set_Should_Not_UnSetFocusedOption_When_EmptyNotAllowed () + { + // Arrange + var slider = new Slider (new List { 1, 2, 3, 4 }) { + AllowEmpty = false + }; + slider.AutoSize = true; + + Assert.NotEmpty (slider.GetSetOptions ()); + + // Act + bool result = slider.UnSetOption (slider.FocusedOption); + + // Assert + Assert.False (result); + Assert.NotEmpty (slider.GetSetOptions()); + } + + // Add more tests for different scenarios and edge cases. +} + +