diff --git a/Examples/UICatalog/Scenarios/DimAutoDemo.cs b/Examples/UICatalog/Scenarios/DimAutoDemo.cs index ba7acf4c9f..6c29a4dabf 100644 --- a/Examples/UICatalog/Scenarios/DimAutoDemo.cs +++ b/Examples/UICatalog/Scenarios/DimAutoDemo.cs @@ -167,11 +167,10 @@ private static FrameView CreateSliderFrameView () List options = ["One", "Two", "Three", "Four"]; - LinearRange linearRange = new (options) + LinearMultiSelector linearRange = new (options) { X = 0, Y = 0, - Type = LinearRangeType.Multiple, AllowEmpty = false, BorderStyle = LineStyle.Double, Title = "_LinearRange" diff --git a/Examples/UICatalog/Scenarios/LinearRanges.cs b/Examples/UICatalog/Scenarios/LinearRanges.cs index 852773d725..eef953c5bc 100644 --- a/Examples/UICatalog/Scenarios/LinearRanges.cs +++ b/Examples/UICatalog/Scenarios/LinearRanges.cs @@ -1,9 +1,8 @@ -using System.Collections.ObjectModel; -using System.Text; +using System.Collections.ObjectModel; namespace UICatalog.Scenarios; -[ScenarioMetadata ("LinearRanges", "Demonstrates the LinearRange view.")] +[ScenarioMetadata ("LinearRanges", "Demonstrates the LinearSelector / LinearMultiSelector / LinearRange views.")] [ScenarioCategory ("Controls")] public class LinearRanges : Scenario { @@ -16,601 +15,122 @@ public override void Main () using Window mainWindow = new (); mainWindow.Title = GetQuitKeyAndName (); - MakeSliders ( - mainWindow, - [ - 500, - 1000, - 1500, - 2000, - 2500, - 3000, - 3500, - 4000, - 4500, - 5000 - ] - ); + ObservableCollection eventSource = []; - FrameView configView = new () + ListView eventLog = new () { - Title = "Confi_guration", X = Pos.Percent (50), Y = 0, Width = Dim.Fill (), Height = Dim.Fill (), - SchemeName = "Dialog" + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent), + Title = "Events", + BorderStyle = LineStyle.Single, + Source = new ListWrapper (eventSource) }; + mainWindow.Add (eventLog); - mainWindow.Add (configView); - - #region Config LinearRange - - LinearRange optionsSlider = new () + // ---- LinearSelector --------------------------------------------------------------- + LinearSelector single = new ([10, 20, 30, 40, 50]) { - Title = "Options", + Title = "_Single (LinearSelector)", X = 0, Y = 0, - Width = Dim.Fill (), - Type = LinearRangeType.Multiple, - AllowEmpty = true, - BorderStyle = LineStyle.Single - }; - - optionsSlider.Style.SetChar = optionsSlider.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - optionsSlider.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Black); - - optionsSlider.Options = - [ - new () { Legend = "Legends" }, - new () { Legend = "RangeAllowSingle" }, - new () { Legend = "EndSpacing" }, - new () { Legend = "DimAuto" } - ]; - - configView.Add (optionsSlider); - - optionsSlider.OptionsChanged += OnOptionsSliderOnOptionsChanged; - optionsSlider.SetOption (0); // Legends - optionsSlider.SetOption (1); // RangeAllowSingle - optionsSlider.SetOption (3); // DimAuto - - CheckBox dimAutoUsesMin = new () - { - Text = "Use minimum size (vs. ideal)", - X = 0, - Y = Pos.Bottom (optionsSlider) + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = false }; + single.Value = 30; - dimAutoUsesMin.ValueChanging += (_, _) => - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.UseMinimumSize = !s.UseMinimumSize; - } - }; - configView.Add (dimAutoUsesMin); + single.ValueChanged += (_, args) => + { + eventSource.Add ($"Single ValueChanged: {args.OldValue} -> {args.NewValue}"); + eventLog.MoveDown (); + }; + mainWindow.Add (single); - #region LinearRange Orientation LinearRange - - LinearRange orientationSlider = new (new () { "Horizontal", "Vertical" }) + // ---- LinearMultiSelector ------------------------------------------------------- + LinearMultiSelector multi = new (["Red", "Green", "Blue", "Yellow"]) { - Title = "LinearRange Orientation", + Title = "_Multiple (LinearMultiSelector)", X = 0, - Y = Pos.Bottom (dimAutoUsesMin) + 1, - BorderStyle = LineStyle.Single + Y = Pos.Bottom (single), + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = true }; + multi.Value = ["Red", "Blue"]; - orientationSlider.SetOption (0); - - configView.Add (orientationSlider); - - orientationSlider.OptionsChanged += OnOrientationSliderOnOptionsChanged; - - #endregion LinearRange Orientation LinearRange - - #region Legends Orientation LinearRange + multi.ValueChanged += (_, args) => + { + eventSource.Add ($"Multi ValueChanged: [{string.Join (",", args.NewValue ?? [])}]"); + eventLog.MoveDown (); + }; + mainWindow.Add (multi); - LinearRange legendsOrientationSlider = new (["Horizontal", "Vertical"]) + // ---- LinearRange (closed) --------------------------------------------------------- + LinearRange closed = new ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { - Title = "Legends Orientation", + Title = "_Closed (LinearRange)", X = 0, - Y = Pos.Bottom (orientationSlider) + 1, - BorderStyle = LineStyle.Single - }; - - legendsOrientationSlider.SetOption (0); - - configView.Add (legendsOrientationSlider); - - legendsOrientationSlider.OptionsChanged += OnLegendsOrientationSliderOnOptionsChanged; - - #endregion Legends Orientation LinearRange - - #region Spacing Options - - FrameView spacingOptions = new () - { - Title = "Spacing Options", - X = Pos.Right (orientationSlider), - Y = Pos.Top (orientationSlider), - Width = Dim.Fill (), - Height = Dim.Auto (), - BorderStyle = LineStyle.Single - }; - - Label label = new () - { - Text = "Min _Inner Spacing:" - }; - - NumericUpDown innerSpacingUpDown = new () - { - X = Pos.Right (label) + 1 + Y = Pos.Bottom (multi), + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = true, + RangeAllowSingle = true, + RangeKind = LinearRangeSpanKind.Closed }; - innerSpacingUpDown.Value = mainWindow.SubViews.OfType ().First ().MinimumInnerSpacing; + closed.ValueChanged += (_, args) => + { + LinearRangeSpan v = args.NewValue; - innerSpacingUpDown.ValueChanging += (_, e) => - { - if (e.NewValue < 0) - { - e.Handled = true; + eventSource.Add ( + $"Closed ValueChanged: kind={v.Kind} start={v.Start} end={v.End} (idx {v.StartIndex}..{v.EndIndex})"); + eventLog.MoveDown (); + }; + mainWindow.Add (closed); - return; - } - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.MinimumInnerSpacing = e.NewValue; - } - }; - - spacingOptions.Add (label, innerSpacingUpDown); - configView.Add (spacingOptions); - - #endregion - - #region Color LinearRange - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.Style.OptionChar = s.Style.OptionChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - s.Style.SetChar = s.Style.SetChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - s.Style.LegendAttributes.SetAttribute = mainWindow.GetAttributeForRole (VisualRole.Normal); - s.Style.RangeChar = s.Style.RangeChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - } - - LinearRange<(Color, Color)> sliderFgColor = new () + // ---- LinearRange (left-bounded) --------------------------------------------------- + LinearRange leftBounded = new ([1, 2, 3, 4, 5]) { - Title = "FG Color", + Title = "_LeftBounded (LinearRange)", X = 0, - Y = Pos.Bottom (legendsOrientationSlider) - + 1, - Type = LinearRangeType.Single, + Y = Pos.Bottom (closed), + Width = Dim.Percent (50), BorderStyle = LineStyle.Single, - AllowEmpty = false, - Orientation = Orientation.Vertical, - LegendsOrientation = Orientation.Horizontal, - MinimumInnerSpacing = 0, - UseMinimumSize = true + RangeKind = LinearRangeSpanKind.LeftBounded, + AllowEmpty = true }; - sliderFgColor.Style.SetChar = sliderFgColor.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - sliderFgColor.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Blue); - - List> colorOptions = []; - - colorOptions.AddRange ( - from colorIndex in Enum.GetValues () - let colorName = colorIndex.ToString () - select new LinearRangeOption<(Color, Color)> - { Data = (new (colorIndex), new (colorIndex)), Legend = colorName, LegendAbbr = (Rune)colorName [0] }); - - sliderFgColor.Options = colorOptions; - - configView.Add (sliderFgColor); - - sliderFgColor.OptionsChanged += OnSliderFgColorOnOptionsChanged; + leftBounded.ValueChanged += (_, args) => + { + eventSource.Add ($"LeftBounded ValueChanged: end={args.NewValue.End} (idx {args.NewValue.EndIndex})"); + eventLog.MoveDown (); + }; + mainWindow.Add (leftBounded); - LinearRange<(Color, Color)> sliderBgColor = new () + // ---- LinearRange (right-bounded) -------------------------------------------------- + LinearRange rightBounded = new ([1, 2, 3, 4, 5]) { - Title = "BG Color", - X = Pos.Right (sliderFgColor), - Y = Pos.Top (sliderFgColor), - Type = LinearRangeType.Single, + Title = "_RightBounded (LinearRange)", + X = 0, + Y = Pos.Bottom (leftBounded), + Width = Dim.Percent (50), BorderStyle = LineStyle.Single, - AllowEmpty = false, - Orientation = Orientation.Vertical, - LegendsOrientation = Orientation.Horizontal, - MinimumInnerSpacing = 0, - UseMinimumSize = true + RangeKind = LinearRangeSpanKind.RightBounded, + AllowEmpty = true }; - sliderBgColor.Style.SetChar = sliderBgColor.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - sliderBgColor.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Blue); - - sliderBgColor.Options = colorOptions; - - configView.Add (sliderBgColor); - - sliderBgColor.OptionsChanged += (_, e) => - { - if (e.Options.Count == 0) - { - return; - } - - (Color, Color) data = e.Options.First ().Value.Data; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.SetScheme ( - new (s.GetScheme ()) - { - Normal = new ( - s.GetAttributeForRole (VisualRole.Normal).Foreground, - data.Item2 - ) - }); - } - }; - - #endregion Color LinearRange - - #endregion Config LinearRange - - ObservableCollection eventSource = []; - - ListView eventLog = new () - { - X = Pos.Right (sliderBgColor), - Y = Pos.Bottom (spacingOptions), - Width = Dim.Fill (), - Height = Dim.Fill (), - SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent), - Source = new ListWrapper (eventSource) - }; - configView.Add (eventLog); - - foreach (View view in mainWindow.SubViews.Where (v => v is LinearRange)!) - { - var slider = (LinearRange)view; - - slider.Accepting += (_, args) => - { - eventSource.Add ($"Accept: {string.Join (",", slider.GetSetOptions ())}"); - eventLog.MoveDown (); - args.Handled = true; - }; - - slider.OptionsChanged += (_, args) => + rightBounded.ValueChanged += (_, args) => { - eventSource.Add ($"OptionsChanged: {string.Join (",", slider.GetSetOptions ())}"); + eventSource.Add ($"RightBounded ValueChanged: start={args.NewValue.Start} (idx {args.NewValue.StartIndex})"); eventLog.MoveDown (); - args.Cancel = true; }; - } + mainWindow.Add (rightBounded); mainWindow.FocusDeepest (NavigationDirection.Forward, null); app.Run (mainWindow); - - return; - - void OnSliderFgColorOnOptionsChanged (object _, LinearRangeEventArgs<(Color, Color)> e) - { - if (e.Options.Count == 0) - { - return; - } - - (Color, Color) data = e.Options.First ().Value.Data; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.SetScheme ( - new (s.GetScheme ()) - { - Normal = new ( - data.Item2, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }); - - s.Style.OptionChar = s.Style.OptionChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.SetChar = s.Style.SetChar with - { - Attribute = new Attribute ( - data.Item1, - s.Style.SetChar.Attribute?.Background - ?? s.GetAttributeForRole (VisualRole.Normal).Background, - s.Style.SetChar.Attribute?.Style - ?? s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.LegendAttributes.SetAttribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style); - - s.Style.RangeChar = s.Style.RangeChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.SpaceChar = s.Style.SpaceChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.LegendAttributes.NormalAttribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style); - } - } - - void OnLegendsOrientationSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - if (e.Options.ContainsKey (0)) - { - s.LegendsOrientation = Orientation.Horizontal; - } - else if (e.Options.ContainsKey (1)) - { - s.LegendsOrientation = Orientation.Vertical; - } - - if (optionsSlider.GetSetOptions ().Contains (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - - void OnOrientationSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - View prev = null; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - if (e.Options.ContainsKey (0)) - { - s.Orientation = Orientation.Horizontal; - - s.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; - - if (prev == null) - { - s.Y = 0; - } - else - { - s.Y = Pos.Bottom (prev) + 1; - } - - s.X = 0; - prev = s; - } - else if (e.Options.ContainsKey (1)) - { - s.Orientation = Orientation.Vertical; - - s.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () }; - - if (prev == null) - { - s.X = 0; - } - else - { - s.X = Pos.Right (prev) + 2; - } - - s.Y = 0; - prev = s; - } - - if (optionsSlider.GetSetOptions ().Contains (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - - void OnOptionsSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.ShowLegends = e.Options.ContainsKey (0); - s.RangeAllowSingle = e.Options.ContainsKey (1); - s.ShowEndSpacing = e.Options.ContainsKey (2); - - if (e.Options.ContainsKey (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - } - - private void MakeSliders (Window window, List options) - { - List types = Enum.GetValues (typeof (LinearRangeType)).Cast ().ToList (); - LinearRange prev = null; - - foreach (LinearRange view in types.Select (type => new LinearRange (options) - { - Title = type.ToString (), - X = 0, - Y = prev == null ? 0 : Pos.Bottom (prev), - BorderStyle = LineStyle.Single, - Type = type, - AllowEmpty = true - })) - { - //view.Padding.Thickness = new (0,1,0,0); - window.Add (view); - prev = view; - } - - List singleOptions = - [ - 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 - ]; - - LinearRange single = new (singleOptions) - { - Title = "_Continuous", - X = 0, - Y = prev == null ? 0 : Pos.Bottom (prev), - Type = LinearRangeType.Single, - BorderStyle = LineStyle.Single, - AllowEmpty = false - }; - - single.SubViewLayout += (_, _) => - { - if (single.Orientation == Orientation.Horizontal) - { - single.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; - single.Style.OptionChar = new () { Grapheme = Glyphs.HLine.ToString () }; - } - else - { - single.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () }; - single.Style.OptionChar = new () { Grapheme = Glyphs.VLine.ToString () }; - } - }; - single.Style.SetChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - single.Style.DragChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - - window.Add (single); - - single.OptionsChanged += (_, e) => { single.Title = $"_Continuous {e.Options.FirstOrDefault ().Key}"; }; - - List oneOption = new () { "The Only Option" }; - - LinearRange one = new (oneOption) - { - Title = "_One Option", - X = 0, - Y = prev == null ? 0 : Pos.Bottom (single), - Type = LinearRangeType.Single, - BorderStyle = LineStyle.Single, - AllowEmpty = false - }; - window.Add (one); } } diff --git a/Examples/UICatalog/Scenarios/Shortcuts.cs b/Examples/UICatalog/Scenarios/Shortcuts.cs index 5c3c90aafa..f0320a678e 100644 --- a/Examples/UICatalog/Scenarios/Shortcuts.cs +++ b/Examples/UICatalog/Scenarios/Shortcuts.cs @@ -205,24 +205,24 @@ private void HandleOnIsRunningChanged (object? sender, EventArgs e) Y = Pos.Bottom (diagnosticShortcut), Width = Dim.Fill (eventLog), HelpText = "LinearRanges work!", - CommandView = new LinearRange { Id = "sliderLR", Orientation = Orientation.Horizontal, AllowEmpty = true }, + CommandView = new LinearMultiSelector { Id = "sliderLR", Orientation = Orientation.Horizontal, AllowEmpty = true }, Key = Key.F5 }; - ((LinearRange)sliderShortcut.CommandView).Options = + ((LinearMultiSelector)sliderShortcut.CommandView).Options = [ new LinearRangeOption { Legend = "A" }, new LinearRangeOption { Legend = "B" }, new LinearRangeOption { Legend = "C" } ]; - ((LinearRange)sliderShortcut.CommandView).SetOption (0); + ((LinearMultiSelector)sliderShortcut.CommandView).Value = ["A"]; - ((LinearRange)sliderShortcut.CommandView).OptionsChanged += (send, _) => + ((LinearMultiSelector)sliderShortcut.CommandView).ValueChanged += (send, args) => { - if (send is LinearRange lr) + if (send is LinearMultiSelector lr) { - eventLog.Log ($"OptionsChanged: { + eventLog.Log ($"ValueChanged: { lr.GetType ().Name } - { - string.Join (",", lr.GetSetOptions ()) + string.Join (",", args.NewValue ?? []) }"); } }; diff --git a/Examples/UICatalog/Scenarios/ViewportSettings.cs b/Examples/UICatalog/Scenarios/ViewportSettings.cs index 427c651902..d92654e3d9 100644 --- a/Examples/UICatalog/Scenarios/ViewportSettings.cs +++ b/Examples/UICatalog/Scenarios/ViewportSettings.cs @@ -90,12 +90,11 @@ public override void Main () List options = ["Option 1", "Option 2", "Option 3"]; - LinearRange linearRange = new (options) + LinearMultiSelector linearRange = new (options) { X = 0, Y = Pos.Bottom (textField) + 1, Orientation = Orientation.Vertical, - Type = LinearRangeType.Multiple, AllowEmpty = false, BorderStyle = LineStyle.Double, Title = "_LinearRange" diff --git a/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs index 4b6ce6e712..e997d42fbd 100644 --- a/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs +++ b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs @@ -58,7 +58,7 @@ internal static class ConfigPropertyHostTypes typeof (FileDialogStyle), typeof (FrameView), typeof (HexView), - typeof (LinearRange), + typeof (LinearRangeDefaults), typeof (Menu), typeof (MenuBar), typeof (MessageBox), @@ -89,7 +89,7 @@ internal static class ConfigPropertyHostTypes [DynamicDependency (PreservedMembers, typeof (FileDialogStyle))] [DynamicDependency (PreservedMembers, typeof (FrameView))] [DynamicDependency (PreservedMembers, typeof (HexView))] - [DynamicDependency (PreservedMembers, typeof (LinearRange))] + [DynamicDependency (PreservedMembers, typeof (LinearRangeDefaults))] [DynamicDependency (PreservedMembers, typeof (Menu))] [DynamicDependency (PreservedMembers, typeof (MenuBar))] [DynamicDependency (PreservedMembers, typeof (MessageBox))] diff --git a/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs b/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs new file mode 100644 index 0000000000..db3a838a14 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui.Views; + +/// +/// Convenience non-generic closed over . +/// Allows designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to +/// discover and create the view without supplying a type argument. +/// +/// +/// +/// To work with non-string option types, use directly. +/// +/// +public class LinearMultiSelector : LinearMultiSelector +{ + /// Initializes a new instance of . + public LinearMultiSelector () { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearMultiSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs b/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs new file mode 100644 index 0000000000..69fc45cb61 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs @@ -0,0 +1,165 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view that allows selection of zero or more options from a typed list. +/// +/// The data type of the options. +/// +/// +/// Exposes the current selection through as +/// ; the list is empty when no options are selected. +/// A defensive copy is taken when is set, so the caller may mutate +/// the list passed in without affecting subsequent reads. +/// +/// +/// Equality between the current value and a new value uses +/// , +/// so two distinct list instances with the same elements in the same order are considered equal. +/// +/// +public class LinearMultiSelector : LinearRangeViewBase>, IDesignable +{ + private static readonly IReadOnlyList _emptyList = new List (0).AsReadOnly (); + + private IReadOnlyList _value = _emptyList; + + /// Initializes a new instance of . + public LinearMultiSelector () : base (LinearRangeRenderMode.Multiple) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearMultiSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Multiple) { } + + /// + /// + /// The setter accepts as a synonym for an empty list. The getter never + /// returns . + /// + public override IReadOnlyList? Value + { + get => _value; + set + { + IReadOnlyList incoming = value is null ? _emptyList : new List (value).AsReadOnly (); + IReadOnlyList current = _value; + + if (SequenceEqualByDefault (current, incoming)) + { + return; + } + + if (RaiseValueChanging (current, incoming)) + { + return; + } + + _value = incoming; + + // Sync indices: find the option index for each element of incoming. + // Use a HashSet to dedupe in O(1) per item rather than O(n) List.Contains scans. + List indices = new (incoming.Count); + HashSet seen = new (incoming.Count); + + foreach (T item in incoming) + { + int idx = IndexOfData (item); + + if (idx >= 0 && seen.Add (idx)) + { + indices.Add (idx); + } + } + + ApplySelectedIndices (indices); + + RaiseValueChanged (current, _value); + } + } + + /// + protected override void OnSelectionChanged () + { + IReadOnlyList previous = _value; + + // Build the new value from current indices in the order they appear in Options + // (rather than the order they were selected) for stable, predictable output. + IReadOnlyList indices = SelectedIndices; + List next = new (indices.Count); + List ordered = new (indices); + ordered.Sort (); + + foreach (int i in ordered) + { + if (i >= 0 && i < Options.Count) + { + next.Add (Options [i].Data!); + } + } + + IReadOnlyList newValue = next.AsReadOnly (); + + if (SequenceEqualByDefault (previous, newValue)) + { + return; + } + + _value = newValue; + RaiseValueChanged (previous, newValue); + } + + private static bool SequenceEqualByDefault (IReadOnlyList a, IReadOnlyList b) + { + if (ReferenceEquals (a, b)) + { + return true; + } + + if (a.Count != b.Count) + { + return false; + } + + EqualityComparer cmp = EqualityComparer.Default; + + for (var i = 0; i < a.Count; i++) + { + if (!cmp.Equals (a [i], b [i])) + { + return false; + } + } + + return true; + } + + /// + /// Loads demo data suitable for a designer preview: a multi-select + /// of the seven days of the week, with the five weekdays + /// (Mon–Fri) preselected. Only populated when is ; + /// for any other type, the view is left untouched and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "Active Days"; + AssignHotKeys = true; + ShowLegends = true; + + string [] days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + Options = days.Select ( + d => new LinearRangeOption (d, (Rune)d [0], (T)(object)d)) + .ToList (); + + Value = days.Take (5).Select (d => (T)(object)d).ToList (); + + return true; + } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRange.cs b/Terminal.Gui/Views/LinearRange/LinearRange.cs index 5fdf4de5ea..2f352c0447 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRange.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRange.cs @@ -1,2024 +1,24 @@ namespace Terminal.Gui.Views; /// -/// Provides a linear range control letting the user navigate from a set of typed options in a linear manner using the -/// keyboard or mouse. +/// Convenience non-generic closed over . Allows +/// designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to discover +/// and create the view without supplying a type argument. /// /// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Left / Right Moves to the previous or next option. -/// -/// -/// Ctrl+Left / Ctrl+Right Moves by a larger step. -/// -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Up / Down Moves to the previous or next option. -/// -/// -/// Ctrl+Up / Ctrl+Down Moves by a larger step. -/// -/// -/// Common key bindings (both orientations): -/// -/// -/// Key Action -/// -/// -/// Home / End Moves to the first or last option. -/// -/// -/// Enter Accepts the current selection (). -/// -/// -/// Space -/// Activates the current selection (). -/// -/// -/// -public class LinearRange : LinearRange -{ - /// - /// Gets or sets the default cursor style. - /// - [ConfigurationProperty (Scope = typeof (ThemeScope))] - public static CursorStyle DefaultCursorStyle { get; set; } = CursorStyle.BlinkingBlock; - - /// Initializes a new instance of the class. - public LinearRange () => Cursor = new Cursor { Style = DefaultCursorStyle }; - - /// Initializes a new instance of the class. - /// Initial options. - /// Initial orientation. - public LinearRange (List options, Orientation orientation = Orientation.Horizontal) : base (options, orientation) { } -} - -/// -/// Provides a type-safe linear range control letting the user navigate from a set of typed options in a linear manner -/// using the keyboard or mouse. -/// -/// The type of the options. -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Left / Right Moves to the previous or next option. -/// -/// -/// Ctrl+Left / Ctrl+Right Moves by a larger step. -/// -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Up / Down Moves to the previous or next option. -/// -/// -/// Ctrl+Up / Ctrl+Down Moves by a larger step. -/// -/// -/// Common key bindings (both orientations): -/// -/// -/// Key Action -/// -/// -/// Home / End Moves to the first or last option. -/// -/// -/// Enter Accepts the current selection (). -/// -/// -/// Space -/// Activates the current selection (). -/// -/// /// -/// Common bindings (Home, End, Enter, Space) are configurable via and -/// . Orientation-dependent cursor bindings are set dynamically -/// and cannot be reconfigured. +/// To work with non-string option types, use directly. /// /// -public class LinearRange : View, IOrientation +public class LinearRange : LinearRange { - /// - /// Gets or sets the view-specific default key bindings for . Contains only bindings - /// unique to this view; shared bindings come from . - /// - /// IMPORTANT: This is a process-wide static property. Change with care. - /// Do not set in parallelizable unit tests. - /// - /// - /// - /// - /// No is applied because is a generic - /// type. Use with key "LinearRange" to override bindings via - /// configuration. - /// - /// - public new static Dictionary? DefaultKeyBindings { get; set; } = new () - { - [Command.Accept] = Bind.All (Key.Enter), - [Command.Activate] = Bind.All (Key.Space), - }; - - private readonly LinearRangeConfiguration _config = new (); - - // List of the current set options. - private readonly List _setOptions = []; - - // Options - private List>? _options; - - private OrientationHelper? _orientationHelper; - - #region Initialize - - private void SetInitialProperties (List> options, Orientation orientation = Orientation.Horizontal) - { - Width = Dim.Auto (DimAutoStyle.Content); - Height = Dim.Auto (DimAutoStyle.Content); - CanFocus = true; - - _options = options; - - // ReSharper disable once UseObjectOrCollectionInitializer - _orientationHelper = new OrientationHelper (this); // Do not use object initializer! - _orientationHelper.Orientation = _config._linearRangeOrientation = orientation; - _orientationHelper.OrientationChanging += (_, e) => OrientationChanging?.Invoke (this, e); - _orientationHelper.OrientationChanged += (_, e) => OrientationChanged?.Invoke (this, e); - - SetDefaultStyle (); - SetCommands (); - SetContentSize (); - - SubViewLayout += (_, _) => { SetContentSize (); }; - } - - // TODO: Make configurable via ConfigurationManager - private void SetDefaultStyle () - { - _config._showLegends = true; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' - Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; // '┼●🗹□⏹' + /// Initializes a new instance of . + public LinearRange () { } - break; - - case Orientation.Vertical: - Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; - Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; - - break; - } - - _config._legendsOrientation = _config._linearRangeOrientation; - Style.EmptyChar = new Cell { Grapheme = " " }; - Style.SetChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; // ■ - Style.RangeChar = new Cell { Grapheme = Glyphs.Stipple.ToString () }; // ░ ▒ ▓ // Medium shade not blinking on curses. - Style.StartRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - Style.EndRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - Style.DragChar = new Cell { Grapheme = Glyphs.Diamond.ToString () }; - } - - #endregion - - #region Constructors - - /// Initializes a new instance of the class. - public LinearRange () : this (new List ()) { } - - /// Initializes a new instance of the class. + /// Initializes a new instance of . /// Initial options. /// Initial orientation. - public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) - { - Cursor = new Cursor { Style = LinearRange.DefaultCursorStyle }; - - if (options is null) - { - return; - } - - if (options is { Count: 0 }) - { - SetInitialProperties ([], orientation); - } - else - { - SetInitialProperties (options.Select (e => - { - var legend = e?.ToString (); - - return new LinearRangeOption - { - Data = e, Legend = legend, LegendAbbr = (Rune)(legend?.Length > 0 ? legend [0] : ' ') - }; - }) - .ToList (), - orientation); - } - } - - #endregion - - #region Properties - - /// - /// Setting the Text of a linear range is a shortcut to setting options. The text is a CSV string of the options. - /// - public override string Text - { - // Return labels as a CSV string - get => _options is null or { Count: 0 } ? string.Empty : string.Join (",", _options); - set - { - if (string.IsNullOrEmpty (value)) - { - Options = []; - } - else - { - IEnumerable list = value.Split (',').Select (x => x.Trim ()); - Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); - } - } - } - - /// Allow no selection. - public bool AllowEmpty - { - get => _config._allowEmpty; - set - { - _config._allowEmpty = value; - - if (!value && _options!.Count > 0 && _setOptions.Count == 0) - { - SetOption (0); - } - } - } - - /// Gets or sets the minimum number of rows/columns between . The default is 1. - public int MinimumInnerSpacing - { - get => _config._minInnerSpacing; - set - { - int current = _config._minInnerSpacing; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnMinimumInnerSpacingChanging, - MinimumInnerSpacingChanging, - newValue => - { - _config._minInnerSpacing = newValue; - SetContentSize (); - }, - OnMinimumInnerSpacingChanged, - MinimumInnerSpacingChanged, - out int _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? MinimumInnerSpacingChanging; - - /// Event raised after the property has changed. - public event EventHandler>? MinimumInnerSpacingChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnMinimumInnerSpacingChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnMinimumInnerSpacingChanged (ValueChangedEventArgs args) { } - - /// LinearRange Type. - public LinearRangeType Type - { - get => _config._type; - set - { - LinearRangeType current = _config._type; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnTypeChanging, - TypeChanging, - newValue => - { - _config._type = newValue; - - // Todo: Custom logic to preserve options. - _setOptions.Clear (); - SetNeedsDraw (); - }, - OnTypeChanged, - TypeChanged, - out LinearRangeType _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? TypeChanging; - - /// Event raised after the property has changed. - public event EventHandler>? TypeChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnTypeChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnTypeChanged (ValueChangedEventArgs args) { } - - /// - /// Gets or sets the . The default is . - /// - public Orientation Orientation { get => _orientationHelper!.Orientation; set => _orientationHelper!.Orientation = value; } - - #region IOrientation members - - /// - public event EventHandler>? OrientationChanging; - - /// - public event EventHandler>? OrientationChanged; - - /// - public void OnOrientationChanged (Orientation newOrientation) - { - _config._linearRangeOrientation = newOrientation; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' - - break; - - case Orientation.Vertical: - Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; - - break; - } - - SetKeyBindings (); - SetContentSize (); - } - - #endregion - - /// Legends Orientation. - public Orientation LegendsOrientation - { - get => _config._legendsOrientation; - set - { - Orientation current = _config._legendsOrientation; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnLegendsOrientationChanging, - LegendsOrientationChanging, - newValue => - { - _config._legendsOrientation = newValue; - SetContentSize (); - }, - OnLegendsOrientationChanged, - LegendsOrientationChanged, - out Orientation _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? LegendsOrientationChanging; - - /// Event raised after the property has changed. - public event EventHandler>? LegendsOrientationChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnLegendsOrientationChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnLegendsOrientationChanged (ValueChangedEventArgs args) { } - - /// LinearRange styles. - public LinearRangeStyle Style { get; set; } = new (); - - /// Set the linear range options. - public List> Options - { - get => _options ?? []; - set - { - // _options should never be null - _options = value ?? throw new ArgumentNullException (nameof (value)); - - if (_options.Count == 0) - { - return; - } - - SetContentSize (); - } - } - - /// 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 ShowEndSpacing - { - get => _config._showEndSpacing; - set - { - bool current = _config._showEndSpacing; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnShowEndSpacingChanging, - ShowEndSpacingChanging, - newValue => - { - _config._showEndSpacing = newValue; - SetContentSize (); - }, - OnShowEndSpacingChanged, - ShowEndSpacingChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? ShowEndSpacingChanging; - - /// Event raised after the property has changed. - public event EventHandler>? ShowEndSpacingChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnShowEndSpacingChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnShowEndSpacingChanged (ValueChangedEventArgs args) { } - - /// Show/Hide the options legends. - public bool ShowLegends - { - get => _config._showLegends; - set - { - bool current = _config._showLegends; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnShowLegendsChanging, - ShowLegendsChanging, - newValue => - { - _config._showLegends = newValue; - SetContentSize (); - }, - OnShowLegendsChanged, - ShowLegendsChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? ShowLegendsChanging; - - /// Event raised after the property has changed. - public event EventHandler>? ShowLegendsChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnShowLegendsChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnShowLegendsChanged (ValueChangedEventArgs args) { } - - /// - /// Gets or sets whether the minimum or ideal size will be used when calculating the size of the linear range. - /// - public bool UseMinimumSize - { - get => _config._useMinimumSize; - set - { - bool current = _config._useMinimumSize; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnUseMinimumSizeChanging, - UseMinimumSizeChanging, - newValue => - { - _config._useMinimumSize = newValue; - SetContentSize (); - }, - OnUseMinimumSizeChanged, - UseMinimumSizeChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? UseMinimumSizeChanging; - - /// Event raised after the property has changed. - public event EventHandler>? UseMinimumSizeChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnUseMinimumSizeChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnUseMinimumSizeChanged (ValueChangedEventArgs args) { } - - #endregion - - #region Events - - /// Event raised when the linear range option/s changed. The dictionary contains: key = option index, value = T - public event EventHandler>? OptionsChanged; - - /// - /// Overridable method called when the linear range options have changed. Raises the - /// event. - /// - public virtual void OnOptionsChanged () - { - OptionsChanged?.Invoke (this, new LinearRangeEventArgs (GetSetOptionDictionary ())); - SetNeedsDraw (); - } - - /// Event raised When the option is hovered with the keys or the mouse. - public event EventHandler>? OptionFocused; - - private 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, LinearRangeEventArgs args) - { - if (newFocusedOption > _options!.Count - 1 || newFocusedOption < 0) - { - return true; - } - - OptionFocused?.Invoke (this, args); - - if (args.Cancel) - { - return args.Cancel; - } - _lastFocusedOption = FocusedOption; - FocusedOption = newFocusedOption; - - return args.Cancel; - } - - #endregion Events - - #region Public Methods - - /// The focused option (has the cursor). - public int FocusedOption - { - get; - set - { - if (field == value) - { - return; - } - field = value; - UpdateCursor (); - } - } - - /// 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) - { - return false; - } - FocusedOption = optionIndex; - SetFocusedOption (); - - return true; - } - - /// Causes the specified option to be un-set and be focused. - public bool UnSetOption (int optionIndex) - { - if (AllowEmpty || _setOptions.Count <= 2 || !_setOptions.Contains (optionIndex)) - { - return false; - } - FocusedOption = optionIndex; - SetFocusedOption (); - - return true; - } - - /// Get the indexes of the set options. - public List GetSetOptions () => _setOptions.OrderBy (e => e).ToList (); - - #endregion Public Methods - - #region Helpers - - private void MoveAndAdd (int x, int y, Rune rune) - { - Move (x, y); - AddRune (rune); - } - - private void MoveAndAdd (int x, int y, string str) - { - Move (x, y); - AddStr (str); - } - - /// Sets the dimensions of the LinearRange to the ideal values. - private void SetContentSize () - { - if (_options is { Count: 0 }) - { - return; - } - - bool horizontal = _config._linearRangeOrientation == Orientation.Horizontal; - - if (UseMinimumSize) - { - CalcSpacingConfig (CalcMinLength ()); - } - else - { - CalcSpacingConfig (horizontal ? Viewport.Width : Viewport.Height); - } - - SetContentSize (new Size (GetIdealWidth (), GetIdealHeight ())); - - return; - - void CalcSpacingConfig (int size) - { - _config._cachedInnerSpacing = 0; - _config._startSpacing = 0; - _config._endSpacing = 0; - - int maxLegend; // Because the legends are centered, the longest one determines inner spacing - - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - maxLegend = int.Max (_options!.Max (s => s.Legend?.GetColumns () ?? 1), 1); - } - else - { - maxLegend = 1; - } - - int minSizeThatFitsLegends = _options!.Count == 1 ? maxLegend : _options.Sum (o => o.Legend!.GetColumns ()); - - string? first; - string? last; - - _config._showLegendsAbbr = false; - - if (minSizeThatFitsLegends > size) - { - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - _config._showLegendsAbbr = true; - - foreach (LinearRangeOption o in _options.Where (op => op.LegendAbbr == default (Rune))) - { - o.LegendAbbr = (Rune)(o.Legend?.GetColumns () > 0 ? o.Legend [0] : ' '); - } - } - - first = "x"; - last = "x"; - } - else - { - first = _options.First ().Legend; - last = _options.Last ().Legend; - } - - // --o-- - // Hello - // Left = He - // Right = lo - int firstLeft = (first!.Length - 1) / 2; // Chars count of the first option to the left. - int lastRight = last!.Length / 2; // Chars count of the last option to the right. - - if (_config._linearRangeOrientation != _config._legendsOrientation) - { - firstLeft = 0; - lastRight = 0; - } - - // -1 because it's better to have an extra space at right than to clip - int width = size - firstLeft - lastRight - 1; - - _config._startSpacing = firstLeft; - - if (_options.Count == 1) - { - _config._cachedInnerSpacing = maxLegend; - } - else - { - _config._cachedInnerSpacing = Math.Max (0, (int)Math.Floor ((double)width / (_options.Count - 1)) - 1); - } - - _config._cachedInnerSpacing = Math.Max (_config._minInnerSpacing, _config._cachedInnerSpacing); - - _config._endSpacing = lastRight; - } - } - - /// Calculates the min dimension required for all options and inner spacing with abbreviated legends - /// - private int CalcMinLength () - { - if (_options is { Count: 0 }) - { - return 0; - } - - var length = 0; - length += _config._startSpacing + _config._endSpacing; - length += _options!.Count; - length += (_options.Count - 1) * _config._minInnerSpacing; - - return length; - } - - /// - /// Gets the ideal width of the linear range. The ideal width is the minimum width required to display all options and - /// inner - /// spacing. - /// - /// - public int GetIdealWidth () - { - if (UseMinimumSize) - { - return Orientation == Orientation.Horizontal ? CalcMinLength () : CalcIdealThickness (); - } - - return Orientation == Orientation.Horizontal ? CalcIdealLength () : CalcIdealThickness (); - } - - /// - /// Gets the ideal height of the linear range. The ideal height is the minimum height required to display all options - /// and - /// inner spacing. - /// - /// - public int GetIdealHeight () - { - if (UseMinimumSize) - { - return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcMinLength (); - } - - return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcIdealLength (); - } - - /// - /// Calculates the ideal dimension required for all options, inner spacing, and legends (non-abbreviated, with one - /// space between). - /// - /// - private int CalcIdealLength () - { - if (_options is { Count: 0 }) - { - return 0; - } - - var length = 0; - - if (!_config._showLegends) - { - return Math.Max (length, CalcMinLength ()); - } - - if (_config._legendsOrientation == _config._linearRangeOrientation && _options!.Count > 0) - { - // Each legend should be centered in a space the width of the longest legend, with one space between. - // Calculate the total length required for all legends. - int maxLegend = int.Max (_options.Max (s => s.Legend?.GetColumns () ?? 1), 1); - length = maxLegend * _options.Count + (_options.Count - 1); - } - else - { - length = CalcMinLength (); - } - - return Math.Max (length, CalcMinLength ()); - } - - /// - /// Calculates the minimum dimension required for the linear range and legends. - /// - /// - private int CalcIdealThickness () - { - var thickness = 1; // Always show the linear range. - - if (!_config._showLegends) - { - return thickness; - } - - if (_config._legendsOrientation != _config._linearRangeOrientation && _options!.Count > 0) - { - thickness += _options.Max (s => s.Legend?.GetColumns () ?? 0); - } - else - { - thickness += 1; - } - - return thickness; - } - - #endregion Helpers - - #region Cursor and Position - - 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._cachedInnerSpacing + 1); - - position = _config._linearRangeOrientation == Orientation.Vertical ? (0, offset) : (offset, 0); - - return true; - } - - /// Tries to get the option index by the position. - /// - /// - /// - /// - /// - internal bool TryGetOptionByPosition (int x, int y, int threshold, out int optionIdx) - { - optionIdx = -1; - - if (Orientation == Orientation.Horizontal) - { - if (y != 0) - { - return false; - } - - for (int xx = x - threshold; xx < x + threshold + 1; xx++) - { - int cx = xx; - cx -= _config._startSpacing; - - int option = cx / (_config._cachedInnerSpacing + 1); - bool valid = cx % (_config._cachedInnerSpacing + 1) == 0; - - if (!valid || option < 0 || option > _options!.Count - 1) - { - continue; - } - - optionIdx = option; - - return true; - } - } - else - { - if (x != 0) - { - return false; - } - - for (int yy = y - threshold; yy < y + threshold + 1; yy++) - { - int cy = yy; - cy -= _config._startSpacing; - - int option = cy / (_config._cachedInnerSpacing + 1); - bool valid = cy % (_config._cachedInnerSpacing + 1) == 0; - - if (!valid || option < 0 || option > _options!.Count - 1) - { - continue; - } - - optionIdx = option; - - return true; - } - } - - return false; - } - - /// Updates the cursor position based on the focused option. - /// - /// This method calculates the cursor position and calls . - /// The framework automatically handles hiding the cursor when the view loses focus. - /// - private void UpdateCursor () - { - if (!TryGetPositionByOption (FocusedOption, out (int x, int y) position) || !IsInitialized || !Viewport.Contains (position.x, position.y)) - { - Cursor = Cursor with { Position = null }; // Hide cursor - - return; - } - - Cursor = Cursor with { Position = ViewportToScreen (new Point (position.x, position.y)) }; - } - - #endregion Cursor and Position - - #region Drawing - - /// - protected override bool OnDrawingContent (DrawContext? context) - { - // TODO: make this more surgical to reduce repaint - - if (_options is null || _options.Count == 0) - { - return true; - } - - // Draw LinearRange - DrawLinearRange (); - - // Draw Legends. - if (_config._showLegends) - { - DrawLegends (); - } - - if (_dragPosition.HasValue && _moveRenderPosition.HasValue) - { - AddStr (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Grapheme); - } - - return true; - } - - private static string AlignText (string? text, int width, Alignment alignment) - { - if (string.IsNullOrEmpty (text)) - { - return ""; - } - - if (text.Length > width) - { - text = text [..width]; - } - - int w = width - text.Length; - string s1 = new (' ', w / 2); - string s2 = new (' ', w % 2); - - // Note: The formatter doesn't handle all of this ??? - switch (alignment) - { - case Alignment.Fill: - return TextFormatter.Justify (text, width); - - case Alignment.Start: - return text + s1 + s1 + s2; - - case Alignment.Center: - if (text.Length % 2 != 0) - { - return s1 + text + s1 + s2; - } - - return s1 + s2 + text + s1; - - case Alignment.End: - return s1 + s1 + s2 + text; - - default: - return text; - } - } - - private void DrawLinearRange () - { - // TODO: be more surgical on clear - ClearViewport (); - - // Attributes - var normalAttr = new Attribute (Color.White, Color.Black); - var setAttr = new Attribute (Color.Black, Color.White); - - if (IsInitialized) - { - normalAttr = GetAttributeForRole (VisualRole.Normal); - setAttr = Style.SetChar.Attribute ?? GetAttributeForRole (VisualRole.HotNormal); - } - - bool isVertical = _config._linearRangeOrientation == Orientation.Vertical; - - var x = 0; - var y = 0; - - bool isSet = _setOptions.Count > 0; - - // Left Spacing - if (_config is { _showEndSpacing: true, _startSpacing: > 0 }) - { - SetAttribute (isSet && _config._type == LinearRangeType.LeftRange - ? Style.RangeChar.Attribute ?? normalAttr - : Style.SpaceChar.Attribute ?? normalAttr); - string text = isSet && _config._type == LinearRangeType.LeftRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; - - for (var i = 0; i < _config._startSpacing; i++) - { - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - else - { - SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); - - for (var i = 0; i < _config._startSpacing; i++) - { - MoveAndAdd (x, y, Style.EmptyChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - - // LinearRange - if (_options!.Count > 0) - { - for (var i = 0; i < _options.Count; i++) - { - var drawRange = false; - - if (isSet) - { - switch (_config._type) - { - case LinearRangeType.LeftRange when i <= _setOptions [0]: - drawRange = i < _setOptions [0]; - - break; - - case LinearRangeType.RightRange when i >= _setOptions [0]: - drawRange = i >= _setOptions [0]; - - break; - - case LinearRangeType.Range when _setOptions.Count == 1: - drawRange = false; - - break; - - case LinearRangeType.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; - } - } - - // Draw Option - SetAttribute (isSet && _setOptions.Contains (i) ? Style.SetChar.Attribute ?? setAttr : - drawRange ? Style.RangeChar.Attribute ?? setAttr : Style.OptionChar.Attribute ?? normalAttr); - - string text = drawRange ? Style.RangeChar.Grapheme : Style.OptionChar.Grapheme; - - if (isSet) - { - if (_setOptions [0] == i) - { - text = Style.StartRangeChar.Grapheme; - } - else if (_setOptions.Count > 1 && _setOptions [1] == i) - { - text = Style.EndRangeChar.Grapheme; - } - else if (_setOptions.Contains (i)) - { - text = Style.SetChar.Grapheme; - } - } - - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - - // Draw Spacing - if (!_config._showEndSpacing && i >= _options.Count - 1) - { - continue; - } - - // Skip if is the Last Spacing. - SetAttribute (drawRange && isSet ? Style.RangeChar.Attribute ?? setAttr : Style.SpaceChar.Attribute ?? normalAttr); - - for (var s = 0; s < _config._cachedInnerSpacing; s++) - { - MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - } - - int remaining = isVertical ? Viewport.Height - y : Viewport.Width - x; - - // Right Spacing - if (_config._showEndSpacing) - { - SetAttribute (isSet && _config._type == LinearRangeType.RightRange - ? Style.RangeChar.Attribute ?? normalAttr - : Style.SpaceChar.Attribute ?? normalAttr); - string text = isSet && _config._type == LinearRangeType.RightRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; - - for (var i = 0; i < remaining; i++) - { - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - else - { - SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); - - for (var i = 0; i < remaining; i++) - { - MoveAndAdd (x, y, Style.EmptyChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - } - - private void DrawLegends () - { - // Attributes - var normalAttr = new Attribute (Color.White, Color.Black); - var setAttr = new Attribute (Color.Black, Color.White); - Attribute spaceAttr = normalAttr; - - if (IsInitialized) - { - normalAttr = Style.LegendAttributes.NormalAttribute ?? GetAttributeForRole (VisualRole.Normal); - setAttr = Style.LegendAttributes.SetAttribute ?? GetAttributeForRole (VisualRole.HotNormal); - spaceAttr = Style.LegendAttributes.EmptyAttribute ?? normalAttr; - } - - bool isTextVertical = _config._legendsOrientation == Orientation.Vertical; - bool isSet = _setOptions.Count > 0; - - var x = 0; - var y = 0; - - Move (x, y); - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: - x += _config._startSpacing; - - break; - - case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: - y += _config._startSpacing; - - break; - } - - if (_config._linearRangeOrientation == Orientation.Horizontal) - { - y += 1; - } - else - { - // Vertical - x += 1; - } - - for (var i = 0; i < _options!.Count; i++) - { - var isOptionSet = false; - - // Check if the Option is Set. - switch (_config._type) - { - case LinearRangeType.Single: - case LinearRangeType.Multiple: - if (isSet && _setOptions.Contains (i)) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.LeftRange: - if (isSet && i <= _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.RightRange: - if (isSet && i >= _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.Range when _setOptions.Count == 1: - if (isSet && i == _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.Range: - if (isSet && ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0]))) - { - isOptionSet = true; - } - - break; - - default: - throw new ArgumentOutOfRangeException (); - } - - // Text || Abbreviation - - string text = (_config._showLegendsAbbr ? _options [i].LegendAbbr.ToString () : _options [i].Legend)!; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - switch (_config._legendsOrientation) - { - case Orientation.Horizontal: - text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); - - 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._cachedInnerSpacing + 1, Alignment.Center); - - break; - } - - break; - } - - // Text - int legendLeftSpacesCount = text.TakeWhile (e => e == ' ').Count (); - int legendRightSpacesCount = text.Reverse ().TakeWhile (e => e == ' ').Count (); - text = text.Trim (); - - // Calculate Start Spacing - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - if (i == 0) - { - // The spacing for the linear range 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 linear range but 0 for the legends. - - int charsLeft = (text.Length - 1) / 2; - legendLeftSpacesCount = _config._startSpacing - charsLeft; - } - - // Option Left Spacing - if (isTextVertical) - { - y += legendLeftSpacesCount; - } - else - { - x += legendLeftSpacesCount; - } - } - - // Legend - SetAttribute (isOptionSet ? setAttr : normalAttr); - - foreach (Rune c in text.EnumerateRunes ()) - { - MoveAndAdd (x, y, c); - - if (isTextVertical) - { - y += 1; - } - else - { - x += 1; - } - } - - // Calculate End Spacing - if (i == _options.Count - 1) - { - // See Start Spacing explanation. - int charsRight = text.Length / 2; - legendRightSpacesCount = _config._endSpacing - charsRight; - } - - // Option Right Spacing of Option - SetAttribute (spaceAttr); - - if (isTextVertical) - { - y += legendRightSpacesCount; - } - else - { - x += legendRightSpacesCount; - } - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: - x += _config._cachedInnerSpacing + 1; - - break; - - case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: - y += _config._cachedInnerSpacing + 1; - - break; - } - } - } - - #endregion Drawing - - #region Keys and Mouse - - // Mouse coordinates of current drag - private Point? _dragPosition; - - // Coordinates of where the "move cursor" is drawn (in OnDrawContent) - private Point? _moveRenderPosition; - - /// - protected override bool OnMouseEvent (Mouse mouse) - { - if (!(mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked) - || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) - || mouse.Flags.FastHasFlags (MouseFlags.PositionReport) - || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased))) - { - return false; - } - - SetFocus (); - - if (!_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) - { - if (mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) - { - _dragPosition = mouse.Position; - _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); - App?.Mouse.GrabMouse (this); - } - - SetNeedsDraw (); - - return true; - } - - bool success; - int option; - - if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.PositionReport) && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) - { - // Continue Drag - _dragPosition = mouse.Position; - _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); - - // how far has user dragged from original location? - if (Orientation == Orientation.Horizontal) - { - success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - } - else - { - success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - } - - if (!_config._allowEmpty && success) - { - if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - } - - SetNeedsDraw (); - - return true; - } - - if ((_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) - { - return mouse.Handled; - } - - // End Drag - App?.Mouse.UngrabMouse (); - _dragPosition = null; - _moveRenderPosition = null; - - switch (Orientation) - { - case Orientation.Horizontal: - success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - - break; - - default: - success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - - break; - } - - if (success) - { - if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - } - - SetNeedsDraw (); - - mouse.Handled = true; - - return mouse.Handled; - - Point ClampMovePosition (Point position) - { - if (Orientation == Orientation.Horizontal) - { - int left = _config._startSpacing; - int width = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; - int right = left + width - 1; - int clampedX = Clamp (position.X, left, right); - position = new Point (clampedX, 0); - } - else - { - int top = _config._startSpacing; - int height = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; - int bottom = top + height - 1; - int clampedY = Clamp (position.Y, top, bottom); - position = new Point (0, clampedY); - } - - return position; - - static int Clamp (int value, int min, int max) => Math.Max (min, Math.Min (max, value)); - } - } - - private void SetCommands () - { - AddCommand (Command.Right, () => MovePlus ()); - AddCommand (Command.Down, () => MovePlus ()); - AddCommand (Command.Left, () => MoveMinus ()); - AddCommand (Command.Up, () => MoveMinus ()); - AddCommand (Command.LeftStart, () => MoveStart ()); - AddCommand (Command.RightEnd, () => MoveEnd ()); - AddCommand (Command.RightExtend, () => ExtendPlus ()); - AddCommand (Command.LeftExtend, () => ExtendMinus ()); - - ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); - - SetKeyBindings (); - } - - // This is called during initialization and anytime orientation changes. - // Orientation-dependent bindings cannot be in DefaultKeyBindings because they vary per instance. - private void SetKeyBindings () - { - // Remove Shift+Cursor extend bindings inherited from View.DefaultKeyBindings; - // LinearRange uses Ctrl+Cursor for extend operations instead. - KeyBindings.Remove (Key.CursorLeft.WithShift); - KeyBindings.Remove (Key.CursorRight.WithShift); - KeyBindings.Remove (Key.CursorUp.WithShift); - KeyBindings.Remove (Key.CursorDown.WithShift); - - if (_config._linearRangeOrientation == Orientation.Horizontal) - { - // Remove before Add: ApplyKeyBindings already bound CursorRight/CursorLeft from View.DefaultKeyBindings - KeyBindings.Remove (Key.CursorRight); - KeyBindings.Add (Key.CursorRight, Command.Right); - KeyBindings.Remove (Key.CursorDown); - KeyBindings.Remove (Key.CursorLeft); - KeyBindings.Add (Key.CursorLeft, Command.Left); - KeyBindings.Remove (Key.CursorUp); - - KeyBindings.Add (Key.CursorRight.WithCtrl, Command.RightExtend); - KeyBindings.Remove (Key.CursorDown.WithCtrl); - KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.LeftExtend); - KeyBindings.Remove (Key.CursorUp.WithCtrl); - } - else - { - KeyBindings.Remove (Key.CursorRight); - // Remove before Add: ApplyKeyBindings already bound CursorDown/CursorUp from View.DefaultKeyBindings - KeyBindings.Remove (Key.CursorDown); - KeyBindings.Add (Key.CursorDown, Command.Down); - KeyBindings.Remove (Key.CursorLeft); - KeyBindings.Remove (Key.CursorUp); - KeyBindings.Add (Key.CursorUp, Command.Up); - - KeyBindings.Remove (Key.CursorRight.WithCtrl); - KeyBindings.Add (Key.CursorDown.WithCtrl, Command.RightExtend); - KeyBindings.Remove (Key.CursorLeft.WithCtrl); - KeyBindings.Add (Key.CursorUp.WithCtrl, Command.LeftExtend); - } - } - - private Dictionary> GetSetOptionDictionary () => _setOptions.ToDictionary (e => e, e => _options! [e]); - - /// - /// Sets or unsets based on . - /// - /// The option to change. - /// If , sets the option. Unsets it otherwise. - public void ChangeOption (int optionIndex, bool set) - { - if (set) - { - if (!_setOptions.Contains (optionIndex)) - { - _setOptions.Add (optionIndex); - - _options? [optionIndex].OnSet (); - } - } - else - { - if (_setOptions.Contains (optionIndex)) - { - _setOptions.Remove (optionIndex); - - _options? [optionIndex].OnUnSet (); - } - } - - // Raise slider changed event. - OnOptionsChanged (); - } - - private bool SetFocusedOption () - { - if (_options is null or { Count: 0 }) - { - return false; - } - - var changed = false; - - switch (_config._type) - { - case LinearRangeType.Single: - case LinearRangeType.LeftRange: - case LinearRangeType.RightRange: - - if (_setOptions.Count == 1) - { - int 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 (); - changed = true; - - break; - - case LinearRangeType.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 (); - changed = true; - - break; - - case LinearRangeType.Range: - if (_config._rangeAllowSingle) - { - if (_setOptions.Count == 1) - { - int 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) - { - int 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 (); - int 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; - } - } - } - } - - // Raise LinearRange Option Changed Event. - OnOptionsChanged (); - changed = true; - - break; - - default: - throw new ArgumentOutOfRangeException (_config._type.ToString ()); - } - - return changed; - } - - internal bool ExtendPlus () - { - int next = _options is { } && FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; - - if (next != FocusedOption && !OnOptionFocused (next, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - - return true; - } - - internal bool ExtendMinus () - { - int prev = FocusedOption > 0 ? FocusedOption - 1 : FocusedOption; - - if (prev != FocusedOption && !OnOptionFocused (prev, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - - return true; - } - - /// - protected override void OnActivated (ICommandContext? ctx) - { - base.OnActivated (ctx); - SetFocusedOption (); - } - - /// - protected override bool OnAccepting (CommandEventArgs args) - { - SetFocusedOption (); - - return false; - } - - internal bool Select () => SetFocusedOption (); - - internal bool Accept (ICommandContext? commandContext) - { - SetFocusedOption (); - - return RaiseAccepting (commandContext) == true; - } - - internal bool MovePlus () - { - bool cancelled = OnOptionFocused (FocusedOption + 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); - - if (cancelled) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveMinus () - { - bool cancelled = OnOptionFocused (FocusedOption - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); - - if (cancelled) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveStart () - { - if (OnOptionFocused (0, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveEnd () - { - if (OnOptionFocused (_options!.Count - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - #endregion + public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs b/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs index f0a94b62d0..33036fb087 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs @@ -1,6 +1,6 @@ namespace Terminal.Gui.Views; -/// All configuration are grouped in this class. +/// All configuration is grouped in this class. internal class LinearRangeConfiguration { internal bool _allowEmpty; @@ -14,6 +14,6 @@ internal class LinearRangeConfiguration internal bool _showLegendsAbbr; internal Orientation _linearRangeOrientation = Orientation.Horizontal; internal int _startSpacing; - internal LinearRangeType _type = LinearRangeType.Single; + internal LinearRangeRenderMode _renderMode = LinearRangeRenderMode.Single; internal bool _useMinimumSize; } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs b/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs new file mode 100644 index 0000000000..445c3f749d --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs @@ -0,0 +1,13 @@ +namespace Terminal.Gui.Views; + +/// +/// Theme-scoped defaults shared by all +/// subclasses (, , +/// ). +/// +public static class LinearRangeDefaults +{ + /// Gets or sets the default cursor style applied to a new linear range view. + [ConfigurationProperty (Scope = typeof (ThemeScope))] + public static CursorStyle DefaultCursorStyle { get; set; } = CursorStyle.BlinkingBlock; +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs b/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs index 47fc52580c..dc9cad34e9 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs @@ -25,7 +25,7 @@ public LinearRangeOption (string legend, Rune legendAbbr, T data) public string? Legend { get; set; } /// - /// Abbreviation of the Legend. When the too small to fit + /// Abbreviation of the Legend. Used when the inner spacing is too small to fit /// . /// public Rune LegendAbbr { get; set; } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs b/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs new file mode 100644 index 0000000000..12adf55e90 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs @@ -0,0 +1,30 @@ +namespace Terminal.Gui.Views; + +/// +/// Selection rendering mode used by to +/// drive selection drawing and hit-testing. Each concrete subclass sets this once +/// in its constructor (or, for , whenever +/// changes). +/// +/// +/// This enum is exposed publicly only because it appears in the protected constructor +/// signature of ; library consumers should +/// pick a concrete subclass rather than instantiate the base directly. +/// +public enum LinearRangeRenderMode +{ + /// One option may be selected at a time. + Single, + + /// Any number of options may be selected at the same time. + Multiple, + + /// A range bounded only by an end point: "everything ≤ End". + LeftSpan, + + /// A range bounded only by a start point: "everything ≥ Start". + RightSpan, + + /// A range bounded by both a start and an end point. + Span +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs b/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs new file mode 100644 index 0000000000..497439cce4 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs @@ -0,0 +1,59 @@ +namespace Terminal.Gui.Views; + +/// +/// Represents the value of a . +/// +/// The data type of the underlying option. +/// +/// +/// A span is one of four kinds: +/// , +/// , +/// , +/// or . +/// +/// +/// To create an empty span, use . +/// To create a closed span between two bounds, use the corresponding constructor and pass the +/// option indices and data values for both ends. +/// +/// +/// StartIndex and EndIndex are option indices into ; +/// they are -1 when not relevant for the current . +/// +/// +public readonly record struct LinearRangeSpan +{ + /// Initializes a new instance of . + /// The kind of span. + /// The start data value (meaningful when is or ). + /// The end data value (meaningful when is or ). + /// The index of in the options list, or -1. + /// The index of in the options list, or -1. + public LinearRangeSpan (LinearRangeSpanKind kind, T? start, T? end, int startIndex, int endIndex) + { + Kind = kind; + Start = start; + End = end; + StartIndex = startIndex; + EndIndex = endIndex; + } + + /// Gets an empty span ( = ). + public static LinearRangeSpan Empty { get; } = new (LinearRangeSpanKind.None, default, default, -1, -1); + + /// Gets the kind of span. + public LinearRangeSpanKind Kind { get; } + + /// Gets the start data value (meaningful when is or ). + public T? Start { get; } + + /// Gets the end data value (meaningful when is or ). + public T? End { get; } + + /// Gets the index of in the options list, or -1. + public int StartIndex { get; } + + /// Gets the index of in the options list, or -1. + public int EndIndex { get; } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs b/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs new file mode 100644 index 0000000000..e004403357 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs @@ -0,0 +1,25 @@ +namespace Terminal.Gui.Views; + +/// +/// Identifies the shape of a . +/// +/// +/// +/// To represent the kind of range a currently holds, set the value via +/// . +/// +/// +public enum LinearRangeSpanKind +{ + /// The span is empty; no option is selected. + None, + + /// The span is bounded only on the right; conceptually "everything ≤ End". + LeftBounded, + + /// The span is bounded only on the left; conceptually "everything ≥ Start". + RightBounded, + + /// The span is closed; both Start and End are bounded. + Closed +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeT.cs b/Terminal.Gui/Views/LinearRange/LinearRangeT.cs new file mode 100644 index 0000000000..46541e60a6 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeT.cs @@ -0,0 +1,308 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view representing a contiguous range of options. The current value is a +/// whose is one of +/// , , +/// , or . +/// +/// The data type of the options. +/// +/// +/// To switch between left-bounded, right-bounded, and closed range modes, set +/// . Setting migrates the current +/// , dropping fields that are no longer relevant. +/// +/// +/// To change the selection programmatically, set . Empty selections may be +/// represented either by or by a span of any +/// with no matching options. +/// +/// +public class LinearRange : LinearRangeViewBase>, IDesignable +{ + private LinearRangeSpan _value = LinearRangeSpan.Empty; + private LinearRangeSpanKind _rangeKind = LinearRangeSpanKind.Closed; + + /// Initializes a new instance of . + public LinearRange () : base (LinearRangeRenderMode.Span) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Span) { } + + /// + /// Gets or sets whether the range is allowed to collapse to a single option (only meaningful + /// when is ). + /// + public bool RangeAllowSingle + { + get => RangeAllowSingleInternal; + set => RangeAllowSingleInternal = value; + } + + /// + /// Gets or sets the kind of range. The default is . + /// + /// + /// + /// Setting this property re-renders the view in the new shape and migrates the current + /// : e.g. switching from to + /// drops the + /// and ; switching to + /// drops the + /// and ; switching to + /// clears the value. + /// + /// + public LinearRangeSpanKind RangeKind + { + get => _rangeKind; + set + { + if (_rangeKind == value) + { + return; + } + + _rangeKind = value; + + // Update internal render mode to match. + RenderMode = value switch + { + LinearRangeSpanKind.LeftBounded => LinearRangeRenderMode.LeftSpan, + LinearRangeSpanKind.RightBounded => LinearRangeRenderMode.RightSpan, + LinearRangeSpanKind.Closed => LinearRangeRenderMode.Span, + _ => LinearRangeRenderMode.Span + }; + + // Migrate current Value to the new kind. + LinearRangeSpan migrated = MigrateValueToKind (_value, value); + + if (!_value.Equals (migrated)) + { + LinearRangeSpan previous = _value; + _value = migrated; + + // Sync indices to reflect the migrated value. + ApplySelectedIndices (IndicesForValue (migrated)); + RaiseValueChanged (previous, migrated); + } + } + } + + /// + public override LinearRangeSpan Value + { + get => _value; + set + { + LinearRangeSpan current = _value; + + if (current.Equals (value)) + { + return; + } + + if (RaiseValueChanging (current, value)) + { + return; + } + + _value = value; + + ApplySelectedIndices (IndicesForValue (value)); + RaiseValueChanged (current, _value); + } + } + + /// + protected override void OnSelectionChanged () + { + LinearRangeSpan previous = _value; + LinearRangeSpan next = SpanFromIndices (SelectedIndices); + + if (previous.Equals (next)) + { + return; + } + + _value = next; + RaiseValueChanged (previous, next); + } + + private LinearRangeSpan SpanFromIndices (IReadOnlyList indices) + { + if (indices.Count == 0) + { + return new LinearRangeSpan (_rangeKind == LinearRangeSpanKind.None ? LinearRangeSpanKind.None : _rangeKind, + default, + default, + -1, + -1); + } + + // Sort to get logical [low, high] + List sorted = new (indices); + sorted.Sort (); + int lo = sorted [0]; + int hi = sorted [^1]; + + switch (_rangeKind) + { + case LinearRangeSpanKind.LeftBounded: + // Only the end is bounded + return new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, + default, + Options [hi].Data, + -1, + hi); + case LinearRangeSpanKind.RightBounded: + // Only the start is bounded + return new LinearRangeSpan (LinearRangeSpanKind.RightBounded, + Options [lo].Data, + default, + lo, + -1); + case LinearRangeSpanKind.Closed: + default: + // Closed (or fallback): both bounds + if (sorted.Count == 1) + { + return new LinearRangeSpan (LinearRangeSpanKind.Closed, + Options [lo].Data, + Options [lo].Data, + lo, + lo); + } + + return new LinearRangeSpan (LinearRangeSpanKind.Closed, + Options [lo].Data, + Options [hi].Data, + lo, + hi); + } + } + + private List IndicesForValue (LinearRangeSpan span) + { + switch (span.Kind) + { + case LinearRangeSpanKind.None: + return []; + case LinearRangeSpanKind.LeftBounded: + { + int end = span.EndIndex >= 0 ? span.EndIndex : IndexOfData (span.End); + + return end >= 0 ? [end] : []; + } + case LinearRangeSpanKind.RightBounded: + { + int start = span.StartIndex >= 0 ? span.StartIndex : IndexOfData (span.Start); + + return start >= 0 ? [start] : []; + } + case LinearRangeSpanKind.Closed: + default: + { + int start = span.StartIndex >= 0 ? span.StartIndex : IndexOfData (span.Start); + int end = span.EndIndex >= 0 ? span.EndIndex : IndexOfData (span.End); + + if (start < 0 && end < 0) + { + return []; + } + + if (start < 0) + { + return [end]; + } + + if (end < 0 || start == end) + { + return [start]; + } + + return [start, end]; + } + } + } + + private static LinearRangeSpan MigrateValueToKind (LinearRangeSpan value, LinearRangeSpanKind newKind) + { + if (newKind == LinearRangeSpanKind.None) + { + return LinearRangeSpan.Empty; + } + + if (value.Kind == LinearRangeSpanKind.None) + { + return value; + } + + return newKind switch + { + LinearRangeSpanKind.LeftBounded => new LinearRangeSpan ( + LinearRangeSpanKind.LeftBounded, + default, + value.Kind == LinearRangeSpanKind.RightBounded ? value.Start : value.End, + -1, + value.Kind == LinearRangeSpanKind.RightBounded ? value.StartIndex : value.EndIndex), + LinearRangeSpanKind.RightBounded => new LinearRangeSpan ( + LinearRangeSpanKind.RightBounded, + value.Kind == LinearRangeSpanKind.LeftBounded ? value.End : value.Start, + default, + value.Kind == LinearRangeSpanKind.LeftBounded ? value.EndIndex : value.StartIndex, + -1), + LinearRangeSpanKind.Closed => value.Kind == LinearRangeSpanKind.Closed + ? value + : value.Kind == LinearRangeSpanKind.LeftBounded + ? new LinearRangeSpan (LinearRangeSpanKind.Closed, value.End, value.End, value.EndIndex, value.EndIndex) + : new LinearRangeSpan (LinearRangeSpanKind.Closed, value.Start, value.Start, value.StartIndex, value.StartIndex), + _ => value + }; + } + + /// + /// Loads demo data suitable for a designer preview: a closed range of work hours + /// (8 AM through 6 PM in one-hour increments) with the range preset to "9 AM"–"5 PM". + /// Only populated when is ; for any other type, + /// the view is left untouched and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "Work Hours"; + AssignHotKeys = true; + ShowLegends = true; + RangeKind = LinearRangeSpanKind.Closed; + RangeAllowSingle = true; + + string [] hours = + [ + "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", + "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM" + ]; + + Options = hours.Select (h => new LinearRangeOption (h, (Rune)h [0], (T)(object)h)).ToList (); + + const int startIdx = 1; // "9 AM" + const int endIdx = 9; // "5 PM" + + Value = new LinearRangeSpan ( + LinearRangeSpanKind.Closed, + (T)(object)hours [startIdx], + (T)(object)hours [endIdx], + startIdx, + endIdx); + + return true; + } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeType.cs b/Terminal.Gui/Views/LinearRange/LinearRangeType.cs deleted file mode 100644 index 0633a416e0..0000000000 --- a/Terminal.Gui/Views/LinearRange/LinearRangeType.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Terminal.Gui.Views; - -/// Types -public enum LinearRangeType -{ - /// - /// - /// ├─┼─┼─┼─┼─█─┼─┼─┼─┼─┼─┼─┤ - /// - /// - Single, - - /// - /// - /// ├─┼─█─┼─┼─█─┼─┼─┼─┼─█─┼─┤ - /// - /// - Multiple, - - /// - /// - /// ├▒▒▒▒▒▒▒▒▒█─┼─┼─┼─┼─┼─┼─┤ - /// - /// - LeftRange, - - /// - /// - /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒▒▒▒▒▒▒┤ - /// - /// - RightRange, - - /// - /// - /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒█─┼─┼─┤ - /// - /// - Range -} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs new file mode 100644 index 0000000000..522b8eac67 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs @@ -0,0 +1,2289 @@ +namespace Terminal.Gui.Views; + +/// +/// Abstract base for linear range views (, +/// , ) that present a list of typed options +/// navigable by keyboard or mouse, and expose the current selection as a strongly-typed value via +/// . +/// +/// The data type carried by each . +/// The shape of ; defined by the concrete subclass. +/// +/// Default key bindings (when is ): +/// +/// +/// Key Action +/// +/// +/// Left / Right Moves to the previous or next option. +/// +/// +/// Ctrl+Left / Ctrl+Right Moves by a larger step. +/// +/// +/// Default key bindings (when is ): +/// +/// +/// Key Action +/// +/// +/// Up / Down Moves to the previous or next option. +/// +/// +/// Ctrl+Up / Ctrl+Down Moves by a larger step. +/// +/// +/// Common key bindings (both orientations): +/// +/// +/// Key Action +/// +/// +/// Home / End Moves to the first or last option. +/// +/// +/// Enter Accepts the current selection (). +/// +/// +/// Space +/// Activates the current selection (). +/// +/// +/// +/// Common bindings (Home, End, Enter, Space) are configurable via and +/// . Orientation-dependent cursor bindings are set dynamically +/// and cannot be reconfigured. +/// +/// +public abstract class LinearRangeViewBase : View, IOrientation, IValue +{ + /// + /// Gets or sets the view-specific default key bindings shared by all linear range views. + /// Contains only bindings unique to this family; shared bindings come from . + /// + /// IMPORTANT: This is a process-wide static property. Change with care. + /// Do not set in parallelizable unit tests. + /// + /// + /// + /// + /// No is applied because this is a generic + /// type. Use with key "LinearRange" to override bindings via + /// configuration. + /// + /// + public new static Dictionary? DefaultKeyBindings { get; set; } = new () + { + [Command.Accept] = Bind.All (Key.Enter), + [Command.Activate] = Bind.All (Key.Space), + }; + + private readonly LinearRangeConfiguration _config = new (); + + // List of the current set options. + private readonly List _setOptions = []; + + // Options + private List>? _options; + + private OrientationHelper? _orientationHelper; + + #region Initialize + + private void SetInitialProperties (List> options, Orientation orientation = Orientation.Horizontal) + { + Width = Dim.Auto (DimAutoStyle.Content); + Height = Dim.Auto (DimAutoStyle.Content); + CanFocus = true; + + _options = options; + + // ReSharper disable once UseObjectOrCollectionInitializer + _orientationHelper = new OrientationHelper (this); // Do not use object initializer! + _orientationHelper.Orientation = _config._linearRangeOrientation = orientation; + _orientationHelper.OrientationChanging += (_, e) => OrientationChanging?.Invoke (this, e); + _orientationHelper.OrientationChanged += (_, e) => OrientationChanged?.Invoke (this, e); + + SetDefaultStyle (); + SetCommands (); + SetContentSize (); + + SubViewLayout += (_, _) => { SetContentSize (); }; + } + + // TODO: Make configurable via ConfigurationManager + private void SetDefaultStyle () + { + _config._showLegends = true; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' + Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; // '┼●🗹□⏹' + + break; + + case Orientation.Vertical: + Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; + Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; + + break; + } + + _config._legendsOrientation = _config._linearRangeOrientation; + Style.EmptyChar = new Cell { Grapheme = " " }; + Style.SetChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; // ■ + Style.RangeChar = new Cell { Grapheme = Glyphs.Stipple.ToString () }; // ░ ▒ ▓ // Medium shade not blinking on curses. + Style.StartRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + Style.EndRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + Style.DragChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + } + + #endregion + + #region Constructors + + /// Initializes a new instance of the class. + /// The selection rendering mode used by this concrete subclass. + protected LinearRangeViewBase (LinearRangeRenderMode renderMode) : this (new List (), Orientation.Horizontal, renderMode) { } + + /// Initializes a new instance of the class. + /// Initial options. + /// Initial orientation. + /// The selection rendering mode used by this concrete subclass. + protected LinearRangeViewBase (List? options, Orientation orientation, LinearRangeRenderMode renderMode) + { + _config._renderMode = renderMode; + Cursor = new Cursor { Style = LinearRangeDefaults.DefaultCursorStyle }; + + if (options is null) + { + return; + } + + if (options is { Count: 0 }) + { + SetInitialProperties ([], orientation); + } + else + { + SetInitialProperties (options.Select (e => + { + var legend = e?.ToString (); + + return new LinearRangeOption + { + Data = e, Legend = legend, LegendAbbr = (Rune)(legend?.Length > 0 ? legend [0] : ' ') + }; + }) + .ToList (), + orientation); + } + } + + #endregion + + #region Properties + + /// + /// Setting the Text of a linear range is a shortcut to setting options. The text is a CSV string of the options. + /// + public override string Text + { + // Return labels as a CSV string + get => _options is null or { Count: 0 } ? string.Empty : string.Join (",", _options); + set + { + if (string.IsNullOrEmpty (value)) + { + Options = []; + } + else + { + IEnumerable list = value.Split (',').Select (x => x.Trim ()); + Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); + } + } + } + + /// Allow no selection. + public bool AllowEmpty + { + get => _config._allowEmpty; + set + { + _config._allowEmpty = value; + + if (!value && _options!.Count > 0 && _setOptions.Count == 0) + { + FocusedOption = 0; + SetFocusedOption (); + } + } + } + + /// Gets or sets the minimum number of rows/columns between . The default is 1. + public int MinimumInnerSpacing + { + get => _config._minInnerSpacing; + set + { + int current = _config._minInnerSpacing; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnMinimumInnerSpacingChanging, + MinimumInnerSpacingChanging, + newValue => + { + _config._minInnerSpacing = newValue; + SetContentSize (); + }, + OnMinimumInnerSpacingChanged, + MinimumInnerSpacingChanged, + out int _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? MinimumInnerSpacingChanging; + + /// Event raised after the property has changed. + public event EventHandler>? MinimumInnerSpacingChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnMinimumInnerSpacingChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnMinimumInnerSpacingChanged (ValueChangedEventArgs args) { } + + /// + /// Gets the internal selection rendering mode set by the concrete subclass. Drives drawing, + /// hit-testing, and the behaviour. + /// + internal LinearRangeRenderMode RenderMode + { + get => _config._renderMode; + set + { + if (_config._renderMode == value) + { + return; + } + + ApplySelectedIndices ([]); + _config._renderMode = value; + SetNeedsDraw (); + } + } + + /// + /// Gets or sets the . The default is . + /// + public Orientation Orientation { get => _orientationHelper!.Orientation; set => _orientationHelper!.Orientation = value; } + + #region IOrientation members + + /// + public event EventHandler>? OrientationChanging; + + /// + public event EventHandler>? OrientationChanged; + + /// + public void OnOrientationChanged (Orientation newOrientation) + { + _config._linearRangeOrientation = newOrientation; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' + + break; + + case Orientation.Vertical: + Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; + + break; + } + + SetKeyBindings (); + SetContentSize (); + } + + #endregion + + /// Legends Orientation. + public Orientation LegendsOrientation + { + get => _config._legendsOrientation; + set + { + Orientation current = _config._legendsOrientation; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnLegendsOrientationChanging, + LegendsOrientationChanging, + newValue => + { + _config._legendsOrientation = newValue; + SetContentSize (); + }, + OnLegendsOrientationChanged, + LegendsOrientationChanged, + out Orientation _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? LegendsOrientationChanging; + + /// Event raised after the property has changed. + public event EventHandler>? LegendsOrientationChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnLegendsOrientationChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnLegendsOrientationChanged (ValueChangedEventArgs args) { } + + /// LinearRange styles. + public LinearRangeStyle Style { get; set; } = new (); + + /// + /// Set the linear range options. When the new options no longer contain the previously selected + /// value(s), the selection is dropped (event semantics depend on the concrete subclass). + /// + public List> Options + { + get => _options ?? []; + set + { + // _options should never be null + _options = value ?? throw new ArgumentNullException (nameof (value)); + + // Drop any selected indices that are no longer valid + _setOptions.RemoveAll (i => i < 0 || i >= _options.Count); + + if (_options.Count == 0) + { + return; + } + + SetContentSize (); + } + } + + /// + /// Internal accessor for whether a range is allowed to collapse to a single option (only meaningful + /// when is ). Exposed publicly only + /// by . + /// + internal bool RangeAllowSingleInternal { get => _config._rangeAllowSingle; set => _config._rangeAllowSingle = value; } + + /// Show/Hide spacing before and after the first and last option. + public bool ShowEndSpacing + { + get => _config._showEndSpacing; + set + { + bool current = _config._showEndSpacing; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnShowEndSpacingChanging, + ShowEndSpacingChanging, + newValue => + { + _config._showEndSpacing = newValue; + SetContentSize (); + }, + OnShowEndSpacingChanged, + ShowEndSpacingChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? ShowEndSpacingChanging; + + /// Event raised after the property has changed. + public event EventHandler>? ShowEndSpacingChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnShowEndSpacingChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnShowEndSpacingChanged (ValueChangedEventArgs args) { } + + /// Show/Hide the options legends. + public bool ShowLegends + { + get => _config._showLegends; + set + { + bool current = _config._showLegends; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnShowLegendsChanging, + ShowLegendsChanging, + newValue => + { + _config._showLegends = newValue; + SetContentSize (); + }, + OnShowLegendsChanged, + ShowLegendsChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? ShowLegendsChanging; + + /// Event raised after the property has changed. + public event EventHandler>? ShowLegendsChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnShowLegendsChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnShowLegendsChanged (ValueChangedEventArgs args) { } + + /// + /// Gets or sets whether the minimum or ideal size will be used when calculating the size of the linear range. + /// + public bool UseMinimumSize + { + get => _config._useMinimumSize; + set + { + bool current = _config._useMinimumSize; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnUseMinimumSizeChanging, + UseMinimumSizeChanging, + newValue => + { + _config._useMinimumSize = newValue; + SetContentSize (); + }, + OnUseMinimumSizeChanged, + UseMinimumSizeChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? UseMinimumSizeChanging; + + /// Event raised after the property has changed. + public event EventHandler>? UseMinimumSizeChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnUseMinimumSizeChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnUseMinimumSizeChanged (ValueChangedEventArgs args) { } + + #endregion + + #region Events + + /// + /// Internal hook fired whenever changes due to user input + /// (keyboard, mouse, command). Concrete subclasses override + /// to compute and publish their . + /// + internal void RaiseSelectionChanged () + { + OnSelectionChanged (); + SetNeedsDraw (); + } + + /// + /// Called by the base when the selected indices have changed due to user input. + /// Concrete subclasses must compute their from the current + /// selection and raise / . + /// + /// + /// Subclasses should not raise from this hook; + /// is reserved for direct writes to . + /// + protected abstract void OnSelectionChanged (); + + /// Event raised When the option is hovered with the keys or the mouse. + public event EventHandler>? OptionFocused; + + private 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, LinearRangeEventArgs args) + { + if (newFocusedOption > _options!.Count - 1 || newFocusedOption < 0) + { + return true; + } + + OptionFocused?.Invoke (this, args); + + if (args.Cancel) + { + return args.Cancel; + } + _lastFocusedOption = FocusedOption; + FocusedOption = newFocusedOption; + + return args.Cancel; + } + + #endregion Events + + #region IValue Implementation + + /// + public abstract TValue? Value { get; set; } + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + public event EventHandler>? ValueChangedUntyped; + + /// Raises . Returns if cancelled. + protected bool RaiseValueChanging (TValue? currentValue, TValue? newValue) + { + ValueChangingEventArgs args = new (currentValue, newValue); + ValueChanging?.Invoke (this, args); + + return args.Handled; + } + + /// Raises and . + protected void RaiseValueChanged (TValue? previousValue, TValue? newValue) + { + ValueChanged?.Invoke (this, new ValueChangedEventArgs (previousValue, newValue)); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (previousValue, newValue)); + } + + #endregion + + #region Selection Helpers (subclass support) + + /// Gets the currently selected option indices, in selection order, as a read-only snapshot. + /// To enumerate the selected option data values, project this list against . + protected internal IReadOnlyList SelectedIndices => _setOptions.AsReadOnly (); + + /// + /// Replaces the selected indices without raising . + /// Used by concrete subclass setters to apply their value back to the index model. + /// + /// The new selected indices. Out-of-range entries are ignored. + protected internal void ApplySelectedIndices (IReadOnlyList indices) + { + if (_options is null) + { + return; + } + + // Unset previous + foreach (int i in _setOptions) + { + if (i >= 0 && i < _options.Count) + { + _options [i].OnUnSet (); + } + } + + _setOptions.Clear (); + + // Set new + foreach (int i in indices) + { + if (i < 0 || i >= _options.Count || _setOptions.Contains (i)) + { + continue; + } + + _setOptions.Add (i); + _options [i].OnSet (); + } + + SetNeedsDraw (); + } + + /// + /// Finds the first option whose equals + /// using the default equality comparer for . + /// + /// The option index, or -1 if no match is found. + protected internal int IndexOfData (TOption? data) + { + if (_options is null) + { + return -1; + } + + EqualityComparer cmp = EqualityComparer.Default; + + for (var i = 0; i < _options.Count; i++) + { + if (cmp.Equals (_options [i].Data, data)) + { + return i; + } + } + + return -1; + } + + #endregion + + #region Public Methods + + /// The focused option (has the cursor). + public int FocusedOption + { + get; + set + { + if (field == value) + { + return; + } + field = value; + UpdateCursor (); + } + } + + #endregion Public Methods + + #region Helpers + + private void MoveAndAdd (int x, int y, Rune rune) + { + Move (x, y); + AddRune (rune); + } + + private void MoveAndAdd (int x, int y, string str) + { + Move (x, y); + AddStr (str); + } + + /// Sets the dimensions of the LinearRange to the ideal values. + private void SetContentSize () + { + if (_options is { Count: 0 }) + { + return; + } + + bool horizontal = _config._linearRangeOrientation == Orientation.Horizontal; + + if (UseMinimumSize) + { + CalcSpacingConfig (CalcMinLength ()); + } + else + { + CalcSpacingConfig (horizontal ? Viewport.Width : Viewport.Height); + } + + SetContentSize (new Size (GetIdealWidth (), GetIdealHeight ())); + + return; + + void CalcSpacingConfig (int size) + { + _config._cachedInnerSpacing = 0; + _config._startSpacing = 0; + _config._endSpacing = 0; + + int maxLegend; // Because the legends are centered, the longest one determines inner spacing + + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + maxLegend = int.Max (_options!.Max (s => s.Legend?.GetColumns () ?? 1), 1); + } + else + { + maxLegend = 1; + } + + int minSizeThatFitsLegends = _options!.Count == 1 ? maxLegend : _options.Sum (o => o.Legend!.GetColumns ()); + + string? first; + string? last; + + _config._showLegendsAbbr = false; + + if (minSizeThatFitsLegends > size) + { + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + _config._showLegendsAbbr = true; + + foreach (LinearRangeOption o in _options.Where (op => op.LegendAbbr == default (Rune))) + { + o.LegendAbbr = (Rune)(o.Legend?.GetColumns () > 0 ? o.Legend [0] : ' '); + } + } + + first = "x"; + last = "x"; + } + else + { + first = _options.First ().Legend; + last = _options.Last ().Legend; + } + + // --o-- + // Hello + // Left = He + // Right = lo + int firstLeft = (first!.Length - 1) / 2; // Chars count of the first option to the left. + int lastRight = last!.Length / 2; // Chars count of the last option to the right. + + if (_config._linearRangeOrientation != _config._legendsOrientation) + { + firstLeft = 0; + lastRight = 0; + } + + // -1 because it's better to have an extra space at right than to clip + int width = size - firstLeft - lastRight - 1; + + _config._startSpacing = firstLeft; + + if (_options.Count == 1) + { + _config._cachedInnerSpacing = maxLegend; + } + else + { + _config._cachedInnerSpacing = Math.Max (0, (int)Math.Floor ((double)width / (_options.Count - 1)) - 1); + } + + _config._cachedInnerSpacing = Math.Max (_config._minInnerSpacing, _config._cachedInnerSpacing); + + _config._endSpacing = lastRight; + } + } + + /// Calculates the min dimension required for all options and inner spacing with abbreviated legends + /// + private int CalcMinLength () + { + if (_options is { Count: 0 }) + { + return 0; + } + + var length = 0; + length += _config._startSpacing + _config._endSpacing; + length += _options!.Count; + length += (_options.Count - 1) * _config._minInnerSpacing; + + return length; + } + + /// + /// Gets the ideal width of the linear range. The ideal width is the minimum width required to display all options and + /// inner + /// spacing. + /// + /// + public int GetIdealWidth () + { + if (UseMinimumSize) + { + return Orientation == Orientation.Horizontal ? CalcMinLength () : CalcIdealThickness (); + } + + return Orientation == Orientation.Horizontal ? CalcIdealLength () : CalcIdealThickness (); + } + + /// + /// Gets the ideal height of the linear range. The ideal height is the minimum height required to display all options + /// and + /// inner spacing. + /// + /// + public int GetIdealHeight () + { + if (UseMinimumSize) + { + return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcMinLength (); + } + + return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcIdealLength (); + } + + /// + /// Calculates the ideal dimension required for all options, inner spacing, and legends (non-abbreviated, with one + /// space between). + /// + /// + private int CalcIdealLength () + { + if (_options is { Count: 0 }) + { + return 0; + } + + var length = 0; + + if (!_config._showLegends) + { + return Math.Max (length, CalcMinLength ()); + } + + if (_config._legendsOrientation == _config._linearRangeOrientation && _options!.Count > 0) + { + // Each legend should be centered in a space the width of the longest legend, with one space between. + // Calculate the total length required for all legends. + int maxLegend = int.Max (_options.Max (s => s.Legend?.GetColumns () ?? 1), 1); + length = maxLegend * _options.Count + (_options.Count - 1); + } + else + { + length = CalcMinLength (); + } + + return Math.Max (length, CalcMinLength ()); + } + + /// + /// Calculates the minimum dimension required for the linear range and legends. + /// + /// + private int CalcIdealThickness () + { + var thickness = 1; // Always show the linear range. + + if (!_config._showLegends) + { + return thickness; + } + + if (_config._legendsOrientation != _config._linearRangeOrientation && _options!.Count > 0) + { + thickness += _options.Max (s => s.Legend?.GetColumns () ?? 0); + } + else + { + thickness += 1; + } + + return thickness; + } + + #endregion Helpers + + #region Cursor and Position + + 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._cachedInnerSpacing + 1); + + position = _config._linearRangeOrientation == Orientation.Vertical ? (0, offset) : (offset, 0); + + return true; + } + + /// Tries to get the option index by the position. + /// + /// + /// + /// + /// + internal bool TryGetOptionByPosition (int x, int y, int threshold, out int optionIdx) + { + optionIdx = -1; + + if (Orientation == Orientation.Horizontal) + { + if (y != 0) + { + return false; + } + + for (int xx = x - threshold; xx < x + threshold + 1; xx++) + { + int cx = xx; + cx -= _config._startSpacing; + + int option = cx / (_config._cachedInnerSpacing + 1); + bool valid = cx % (_config._cachedInnerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options!.Count - 1) + { + continue; + } + + optionIdx = option; + + return true; + } + } + else + { + if (x != 0) + { + return false; + } + + for (int yy = y - threshold; yy < y + threshold + 1; yy++) + { + int cy = yy; + cy -= _config._startSpacing; + + int option = cy / (_config._cachedInnerSpacing + 1); + bool valid = cy % (_config._cachedInnerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options!.Count - 1) + { + continue; + } + + optionIdx = option; + + return true; + } + } + + return false; + } + + /// Updates the cursor position based on the focused option. + /// + /// This method calculates the cursor position and calls . + /// The framework automatically handles hiding the cursor when the view loses focus. + /// + private void UpdateCursor () + { + if (!TryGetPositionByOption (FocusedOption, out (int x, int y) position) || !IsInitialized || !Viewport.Contains (position.x, position.y)) + { + Cursor = Cursor with { Position = null }; // Hide cursor + + return; + } + + Cursor = Cursor with { Position = ViewportToScreen (new Point (position.x, position.y)) }; + } + + #endregion Cursor and Position + + #region Drawing + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + // TODO: make this more surgical to reduce repaint + + if (_options is null || _options.Count == 0) + { + return true; + } + + // Draw LinearRange + DrawLinearRange (); + + // Draw Legends. + if (_config._showLegends) + { + DrawLegends (); + } + + if (_dragPosition.HasValue && _moveRenderPosition.HasValue) + { + AddStr (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Grapheme); + } + + return true; + } + + private static string AlignText (string? text, int width, Alignment alignment) + { + if (string.IsNullOrEmpty (text)) + { + return ""; + } + + if (text.Length > width) + { + text = text [..width]; + } + + int w = width - text.Length; + string s1 = new (' ', w / 2); + string s2 = new (' ', w % 2); + + // Note: The formatter doesn't handle all of this ??? + switch (alignment) + { + case Alignment.Fill: + return TextFormatter.Justify (text, width); + + case Alignment.Start: + return text + s1 + s1 + s2; + + case Alignment.Center: + if (text.Length % 2 != 0) + { + return s1 + text + s1 + s2; + } + + return s1 + s2 + text + s1; + + case Alignment.End: + return s1 + s1 + s2 + text; + + default: + return text; + } + } + + private void DrawLinearRange () + { + // The base View pipeline already calls ClearViewport before OnDrawingContent + // (see View.DoClearViewport). DrawLinearRange + DrawLegends together repaint every cell + // in the Viewport, so a second ClearViewport here is redundant work that — because + // ClearViewport calls SetNeedsDraw — also triggered another draw cycle, causing visible + // flicker during mouse drag on the LinearRange family. + + // Attributes + var normalAttr = new Attribute (Color.White, Color.Black); + var setAttr = new Attribute (Color.Black, Color.White); + + if (IsInitialized) + { + normalAttr = GetAttributeForRole (VisualRole.Normal); + setAttr = Style.SetChar.Attribute ?? GetAttributeForRole (VisualRole.HotNormal); + } + + bool isVertical = _config._linearRangeOrientation == Orientation.Vertical; + + var x = 0; + var y = 0; + + bool isSet = _setOptions.Count > 0; + + // Left Spacing + if (_config is { _showEndSpacing: true, _startSpacing: > 0 }) + { + SetAttribute (isSet && _config._renderMode == LinearRangeRenderMode.LeftSpan + ? Style.RangeChar.Attribute ?? normalAttr + : Style.SpaceChar.Attribute ?? normalAttr); + string text = isSet && _config._renderMode == LinearRangeRenderMode.LeftSpan ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; + + for (var i = 0; i < _config._startSpacing; i++) + { + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + else + { + SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); + + for (var i = 0; i < _config._startSpacing; i++) + { + MoveAndAdd (x, y, Style.EmptyChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + + // LinearRange + if (_options!.Count > 0) + { + for (var i = 0; i < _options.Count; i++) + { + var drawRange = false; + + if (isSet) + { + switch (_config._renderMode) + { + case LinearRangeRenderMode.LeftSpan when i <= _setOptions [0]: + drawRange = i < _setOptions [0]; + + break; + + case LinearRangeRenderMode.RightSpan when i >= _setOptions [0]: + drawRange = i >= _setOptions [0]; + + break; + + case LinearRangeRenderMode.Span when _setOptions.Count == 1: + drawRange = false; + + break; + + case LinearRangeRenderMode.Span 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; + } + } + + // Draw Option + SetAttribute (isSet && _setOptions.Contains (i) ? Style.SetChar.Attribute ?? setAttr : + drawRange ? Style.RangeChar.Attribute ?? setAttr : Style.OptionChar.Attribute ?? normalAttr); + + string text = drawRange ? Style.RangeChar.Grapheme : Style.OptionChar.Grapheme; + + if (isSet) + { + if (_setOptions [0] == i) + { + text = Style.StartRangeChar.Grapheme; + } + else if (_setOptions.Count > 1 && _setOptions [1] == i) + { + text = Style.EndRangeChar.Grapheme; + } + else if (_setOptions.Contains (i)) + { + text = Style.SetChar.Grapheme; + } + } + + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + + // Draw Spacing + if (!_config._showEndSpacing && i >= _options.Count - 1) + { + continue; + } + + // Skip if is the Last Spacing. + SetAttribute (drawRange && isSet ? Style.RangeChar.Attribute ?? setAttr : Style.SpaceChar.Attribute ?? normalAttr); + + for (var s = 0; s < _config._cachedInnerSpacing; s++) + { + MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + } + + int remaining = isVertical ? Viewport.Height - y : Viewport.Width - x; + + // Right Spacing + if (_config._showEndSpacing) + { + SetAttribute (isSet && _config._renderMode == LinearRangeRenderMode.RightSpan + ? Style.RangeChar.Attribute ?? normalAttr + : Style.SpaceChar.Attribute ?? normalAttr); + string text = isSet && _config._renderMode == LinearRangeRenderMode.RightSpan ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; + + for (var i = 0; i < remaining; i++) + { + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + else + { + SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); + + for (var i = 0; i < remaining; i++) + { + MoveAndAdd (x, y, Style.EmptyChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + } + + private void DrawLegends () + { + // Attributes + var normalAttr = new Attribute (Color.White, Color.Black); + Attribute spaceAttr = normalAttr; + + if (IsInitialized) + { + normalAttr = Style.LegendAttributes.NormalAttribute ?? GetAttributeForRole (VisualRole.Normal); + spaceAttr = Style.LegendAttributes.EmptyAttribute ?? normalAttr; + } + + bool isTextVertical = _config._legendsOrientation == Orientation.Vertical; + + var x = 0; + var y = 0; + + Move (x, y); + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: + x += _config._startSpacing; + + break; + + case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: + y += _config._startSpacing; + + break; + } + + if (_config._linearRangeOrientation == Orientation.Horizontal) + { + y += 1; + } + else + { + // Vertical + x += 1; + } + + for (var i = 0; i < _options!.Count; i++) + { + // Text || Abbreviation + + string text = (_config._showLegendsAbbr ? _options [i].LegendAbbr.ToString () : _options [i].Legend)!; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + switch (_config._legendsOrientation) + { + case Orientation.Horizontal: + text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); + + 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._cachedInnerSpacing + 1, Alignment.Center); + + break; + } + + break; + } + + // Text + int legendLeftSpacesCount = text.TakeWhile (e => e == ' ').Count (); + int legendRightSpacesCount = text.Reverse ().TakeWhile (e => e == ' ').Count (); + text = text.Trim (); + + // Calculate Start Spacing + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + if (i == 0) + { + // The spacing for the linear range 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 linear range but 0 for the legends. + + int charsLeft = (text.Length - 1) / 2; + legendLeftSpacesCount = _config._startSpacing - charsLeft; + } + + // Option Left Spacing + if (isTextVertical) + { + y += legendLeftSpacesCount; + } + else + { + x += legendLeftSpacesCount; + } + } + + // Legend - no special styling for set/focused options; the range bar itself indicates selection. + SetAttribute (normalAttr); + + foreach (Rune c in text.EnumerateRunes ()) + { + MoveAndAdd (x, y, c); + + if (isTextVertical) + { + y += 1; + } + else + { + x += 1; + } + } + + // Calculate End Spacing + if (i == _options.Count - 1) + { + // See Start Spacing explanation. + int charsRight = text.Length / 2; + legendRightSpacesCount = _config._endSpacing - charsRight; + } + + // Option Right Spacing of Option + SetAttribute (spaceAttr); + + if (isTextVertical) + { + y += legendRightSpacesCount; + } + else + { + x += legendRightSpacesCount; + } + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: + x += _config._cachedInnerSpacing + 1; + + break; + + case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: + y += _config._cachedInnerSpacing + 1; + + break; + } + } + } + + #endregion Drawing + + #region Keys and Mouse + + // Mouse coordinates of current drag + private Point? _dragPosition; + + // Coordinates of where the "move cursor" is drawn (in OnDrawContent) + private Point? _moveRenderPosition; + + // For Span (Closed) drag: the option that stays fixed while dragging the active end. + // -1 means no drag in progress. + private int _dragAnchorOption = -1; + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (!(mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked) + || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) + || mouse.Flags.FastHasFlags (MouseFlags.PositionReport) + || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased))) + { + return false; + } + + SetFocus (); + + if (!_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + { + if (mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) + { + _dragPosition = mouse.Position; + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); + App?.Mouse.GrabMouse (this); + + // Anchor the selection at the press position so a subsequent drag can extend a range. + // Resolve the option under the press position and set it as the focused option. + bool pressSuccess; + int pressOption; + + if (Orientation == Orientation.Horizontal) + { + pressSuccess = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out pressOption); + } + else + { + pressSuccess = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out pressOption); + } + + if (pressSuccess) + { + if (!OnOptionFocused (pressOption, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + ApplyMouseSelection (pressOption, dragStart: true); + } + } + + // Mark handled so the View pipeline does not later invoke Command.Activate + // on the synthesized Released/Clicked events (which would re-toggle the selection). + mouse.Handled = true; + } + + SetNeedsDraw (); + + return true; + } + + bool success; + int option; + + if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.PositionReport) && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + { + // Continue Drag + _dragPosition = mouse.Position; + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); + + // how far has user dragged from original location? + if (Orientation == Orientation.Horizontal) + { + success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + } + else + { + success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + } + + // Update the selection on drag regardless of AllowEmpty so the user can drag the + // end of a range (or move a single selection) continuously. + if (success) + { + if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + ApplyMouseSelection (option, dragStart: false); + } + } + + SetNeedsDraw (); + + return true; + } + + if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + // End of a drag we initiated. Selection was already updated during drag continues; + // just release the grab. Mark the event handled so the View pipeline does not + // re-invoke Command.Activate (which would toggle the selection back off). + App?.Mouse.UngrabMouse (); + _dragPosition = null; + _moveRenderPosition = null; + _dragAnchorOption = -1; + mouse.Handled = true; + SetNeedsDraw (); + + return true; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) + { + // Click events from the synthesizer that follow a drag are redundant — the drag + // already updated selection. Otherwise (a "real" click without prior press), + // let the View pipeline raise Command.Activate via the default mouse bindings. + return mouse.Handled; + } + + // End Drag + App?.Mouse.UngrabMouse (); + _dragPosition = null; + _moveRenderPosition = null; + _dragAnchorOption = -1; + + switch (Orientation) + { + case Orientation.Horizontal: + success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + + break; + + default: + success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + + break; + } + + if (success) + { + if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + } + + SetNeedsDraw (); + + mouse.Handled = true; + + return mouse.Handled; + + Point ClampMovePosition (Point position) + { + if (Orientation == Orientation.Horizontal) + { + int left = _config._startSpacing; + int width = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; + int right = left + width - 1; + int clampedX = Clamp (position.X, left, right); + position = new Point (clampedX, 0); + } + else + { + int top = _config._startSpacing; + int height = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; + int bottom = top + height - 1; + int clampedY = Clamp (position.Y, top, bottom); + position = new Point (0, clampedY); + } + + return position; + + static int Clamp (int value, int min, int max) => Math.Max (min, Math.Min (max, value)); + } + } + + private void SetCommands () + { + AddCommand (Command.Right, () => MovePlus ()); + AddCommand (Command.Down, () => MovePlus ()); + AddCommand (Command.Left, () => MoveMinus ()); + AddCommand (Command.Up, () => MoveMinus ()); + AddCommand (Command.LeftStart, () => MoveStart ()); + AddCommand (Command.RightEnd, () => MoveEnd ()); + AddCommand (Command.RightExtend, () => ExtendPlus ()); + AddCommand (Command.LeftExtend, () => ExtendMinus ()); + + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); + + SetKeyBindings (); + } + + // This is called during initialization and anytime orientation changes. + // Orientation-dependent bindings cannot be in DefaultKeyBindings because they vary per instance. + private void SetKeyBindings () + { + // Remove Shift+Cursor extend bindings inherited from View.DefaultKeyBindings; + // LinearRange uses Ctrl+Cursor for extend operations instead. + KeyBindings.Remove (Key.CursorLeft.WithShift); + KeyBindings.Remove (Key.CursorRight.WithShift); + KeyBindings.Remove (Key.CursorUp.WithShift); + KeyBindings.Remove (Key.CursorDown.WithShift); + + if (_config._linearRangeOrientation == Orientation.Horizontal) + { + // Remove before Add: ApplyKeyBindings already bound CursorRight/CursorLeft from View.DefaultKeyBindings + KeyBindings.Remove (Key.CursorRight); + KeyBindings.Add (Key.CursorRight, Command.Right); + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Remove (Key.CursorUp); + + KeyBindings.Add (Key.CursorRight.WithCtrl, Command.RightExtend); + KeyBindings.Remove (Key.CursorDown.WithCtrl); + KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.LeftExtend); + KeyBindings.Remove (Key.CursorUp.WithCtrl); + } + else + { + KeyBindings.Remove (Key.CursorRight); + // Remove before Add: ApplyKeyBindings already bound CursorDown/CursorUp from View.DefaultKeyBindings + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Add (Key.CursorDown, Command.Down); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Remove (Key.CursorUp); + KeyBindings.Add (Key.CursorUp, Command.Up); + + KeyBindings.Remove (Key.CursorRight.WithCtrl); + KeyBindings.Add (Key.CursorDown.WithCtrl, Command.RightExtend); + KeyBindings.Remove (Key.CursorLeft.WithCtrl); + KeyBindings.Add (Key.CursorUp.WithCtrl, Command.LeftExtend); + } + } + + private Dictionary> GetSetOptionDictionary () => _setOptions.ToDictionary (e => e, e => _options! [e]); + + /// + /// Applies a mouse-press or mouse-drag selection at . Unlike + /// (which has toggle/extend/shrink semantics designed + /// for keyboard activation), this performs set semantics suitable for a continuous + /// mouse drag: a single-bounded view's endpoint follows the cursor without toggling off + /// when the cursor returns to the existing value, and a Closed range tracks an anchor on + /// the opposite end so the range never collapses unexpectedly while dragging. + /// + /// The option index under the mouse. + /// + /// for the initial press of a drag; + /// for subsequent drag-continue events. + /// + private void ApplyMouseSelection (int option, bool dragStart) + { + if (_options is null or { Count: 0 } || option < 0 || option >= _options.Count) + { + return; + } + + var changed = false; + + switch (_config._renderMode) + { + case LinearRangeRenderMode.Single: + case LinearRangeRenderMode.LeftSpan: + case LinearRangeRenderMode.RightSpan: + changed = ApplyMouseSelectionSingle (option); + + break; + + case LinearRangeRenderMode.Multiple: + if (dragStart) + { + // Toggle on the press option. + changed = ToggleSetOption (option); + } + else + { + // During drag, only ensure the option becomes set (don't toggle it off + // each time the cursor revisits a previously-set option). + if (!_setOptions.Contains (option)) + { + _setOptions.Add (option); + _options [option].OnSet (); + changed = true; + } + } + + break; + + case LinearRangeRenderMode.Span: + changed = ApplyMouseSelectionSpan (option, dragStart); + + break; + + default: + throw new ArgumentOutOfRangeException (_config._renderMode.ToString ()); + } + + if (changed) + { + RaiseSelectionChanged (); + } + } + + private bool ApplyMouseSelectionSingle (int option) + { + if (_setOptions.Count == 1 && _setOptions [0] == option) + { + // Already the single set option; no change. Critically, do NOT toggle off here: + // a drag through the same option must not clear the selection. + return false; + } + + foreach (int existing in _setOptions) + { + _options! [existing].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (option); + _options! [option].OnSet (); + + return true; + } + + private bool ToggleSetOption (int option) + { + if (_setOptions.Contains (option)) + { + if (!_config._allowEmpty && _setOptions.Count == 1) + { + return false; + } + + _setOptions.Remove (option); + _options! [option].OnUnSet (); + } + else + { + _setOptions.Add (option); + _options! [option].OnSet (); + } + + return true; + } + + private bool ApplyMouseSelectionSpan (int option, bool dragStart) + { + // Empty range: just place a single point at option. + if (_setOptions.Count == 0) + { + if (_config._rangeAllowSingle) + { + _setOptions.Add (option); + _options! [option].OnSet (); + _dragAnchorOption = option; + + return true; + } + + // Closed range with rangeAllowSingle = false: span [option, option+1] (or option-1). + int next = option < _options!.Count - 1 ? option + 1 : option - 1; + int lo = Math.Min (option, next); + int hi = Math.Max (option, next); + _setOptions.Add (lo); + _setOptions.Add (hi); + _options! [lo].OnSet (); + _options! [hi].OnSet (); + + // Anchor at the press location so a subsequent drag moves the OTHER end. + _dragAnchorOption = option; + + return true; + } + + if (dragStart) + { + // Choose anchor for the upcoming drag. + if (_setOptions.Count == 1) + { + int existing = _setOptions [0]; + + if (option == existing) + { + // Press on the single existing point: anchor stays here; no change yet. + _dragAnchorOption = existing; + + return false; + } + + int lo = Math.Min (existing, option); + int hi = Math.Max (existing, option); + _setOptions.Clear (); + _setOptions.Add (lo); + _setOptions.Add (hi); + _options! [option].OnSet (); + _dragAnchorOption = existing; + + return true; + } + + // Count == 2: pick anchor as the endpoint farther from the press; the closer + // endpoint becomes the active end and is moved to the press position. + int lowEnd = _setOptions [0]; + int highEnd = _setOptions [1]; + + if (option <= lowEnd) + { + // Press at or to the left of the range: anchor=high, active=low. + _dragAnchorOption = highEnd; + + if (option == lowEnd) + { + return false; + } + + _options! [lowEnd].OnUnSet (); + _setOptions [0] = option; + _options! [option].OnSet (); + + return true; + } + + if (option >= highEnd) + { + _dragAnchorOption = lowEnd; + + if (option == highEnd) + { + return false; + } + + _options! [highEnd].OnUnSet (); + _setOptions [1] = option; + _options! [option].OnSet (); + + return true; + } + + // Inside the range: pick the closer endpoint as active; the other is the anchor. + int distLow = option - lowEnd; + int distHigh = highEnd - option; + + if (distLow <= distHigh) + { + _options! [lowEnd].OnUnSet (); + _setOptions [0] = option; + _options! [option].OnSet (); + _dragAnchorOption = highEnd; + } + else + { + _options! [highEnd].OnUnSet (); + _setOptions [1] = option; + _options! [option].OnSet (); + _dragAnchorOption = lowEnd; + } + + return true; + } + + // Drag continue: keep _dragAnchorOption fixed, move the active end to option. + if (_dragAnchorOption < 0) + { + // Anchor was never set (e.g. drag without prior press in our pipeline). Treat as start. + return ApplyMouseSelectionSpan (option, dragStart: true); + } + + int anchor = _dragAnchorOption; + int newLo = Math.Min (anchor, option); + int newHi = Math.Max (anchor, option); + + if (newLo == newHi) + { + // Collapsed to a single point. + if (!_config._rangeAllowSingle) + { + // Closed range with rangeAllowSingle=false cannot collapse; keep current state. + return false; + } + + if (_setOptions.Count == 1 && _setOptions [0] == newLo) + { + return false; + } + + foreach (int s in _setOptions) + { + _options! [s].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (newLo); + _options! [newLo].OnSet (); + + return true; + } + + if (_setOptions.Count == 2 && _setOptions [0] == newLo && _setOptions [1] == newHi) + { + return false; + } + + foreach (int s in _setOptions) + { + _options! [s].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (newLo); + _setOptions.Add (newHi); + _options! [newLo].OnSet (); + _options! [newHi].OnSet (); + + return true; + } + + private bool SetFocusedOption () + { + if (_options is null or { Count: 0 }) + { + return false; + } + + var changed = false; + + switch (_config._renderMode) + { + case LinearRangeRenderMode.Single: + case LinearRangeRenderMode.LeftSpan: + case LinearRangeRenderMode.RightSpan: + + if (_setOptions.Count == 1) + { + int 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. + RaiseSelectionChanged (); + changed = true; + + break; + + case LinearRangeRenderMode.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 (); + } + + RaiseSelectionChanged (); + changed = true; + + break; + + case LinearRangeRenderMode.Span: + if (_config._rangeAllowSingle) + { + if (_setOptions.Count == 1) + { + int 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) + { + int 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 (); + int 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; + } + } + } + } + + // Raise LinearRange Option Changed Event. + RaiseSelectionChanged (); + changed = true; + + break; + + default: + throw new ArgumentOutOfRangeException (_config._renderMode.ToString ()); + } + + return changed; + } + + internal bool ExtendPlus () + { + int next = _options is { } && FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; + + if (next != FocusedOption && !OnOptionFocused (next, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + + return true; + } + + internal bool ExtendMinus () + { + int prev = FocusedOption > 0 ? FocusedOption - 1 : FocusedOption; + + if (prev != FocusedOption && !OnOptionFocused (prev, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + + return true; + } + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + SetFocusedOption (); + } + + /// + protected override bool OnAccepting (CommandEventArgs args) + { + SetFocusedOption (); + + return false; + } + + internal bool Select () => SetFocusedOption (); + + internal bool Accept (ICommandContext? commandContext) + { + SetFocusedOption (); + + return RaiseAccepting (commandContext) == true; + } + + internal bool MovePlus () + { + bool cancelled = OnOptionFocused (FocusedOption + 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); + + if (cancelled) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveMinus () + { + bool cancelled = OnOptionFocused (FocusedOption - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); + + if (cancelled) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveStart () + { + if (OnOptionFocused (0, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveEnd () + { + if (OnOptionFocused (_options!.Count - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + #endregion +} diff --git a/Terminal.Gui/Views/LinearRange/LinearSelector.cs b/Terminal.Gui/Views/LinearRange/LinearSelector.cs new file mode 100644 index 0000000000..e6d12592f2 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearSelector.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui.Views; + +/// +/// Convenience non-generic closed over . Allows +/// designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to discover +/// and create the view without supplying a type argument. +/// +/// +/// +/// To work with non-string option types, use directly. +/// +/// +public class LinearSelector : LinearSelector +{ + /// Initializes a new instance of . + public LinearSelector () { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs b/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs new file mode 100644 index 0000000000..3159b7ecc6 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs @@ -0,0 +1,185 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view that allows selection of a single option from a typed list of options. +/// +/// The data type of the options. +/// +/// +/// Exposes the current selection through . When is a +/// reference type, unambiguously represents "no selection". When +/// is a value type, is default(T) when no +/// option is selected — which can be indistinguishable from a legitimately selected default value +/// (e.g. 0 for ). To test for empty selection unambiguously for both +/// reference and value types, use , which is +/// only when nothing is selected. +/// +/// +/// To switch the current selection programmatically, set or +/// . To observe selection changes, subscribe to +/// . +/// +/// +public class LinearSelector : LinearRangeViewBase, IDesignable +{ + private T? _value; + + /// Initializes a new instance of . + public LinearSelector () : base (LinearRangeRenderMode.Single) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Single) { } + + /// + public override T? Value + { + get => _value; + set + { + T? current = _value; + + if (EqualityComparer.Default.Equals (current, value)) + { + return; + } + + if (RaiseValueChanging (current, value)) + { + return; + } + + _value = value; + + // Sync indices to match value. + if (value is null) + { + ApplySelectedIndices ([]); + } + else + { + int idx = IndexOfData (value); + + if (idx >= 0) + { + ApplySelectedIndices ([idx]); + } + else + { + // Value not present among options: clear selection but keep field. + ApplySelectedIndices ([]); + } + } + + RaiseValueChanged (current, _value); + } + } + + /// + /// Gets or sets the index of the currently selected option, or if no option is + /// selected. This is the unambiguous "no selection" surface for both reference and value types + /// (compare with , where default(T) for value types may collide with a + /// legitimately selected option). + /// + /// + /// + /// To clear the selection, set to . Requires + /// to be ; + /// otherwise the clear is silently ignored (mirrors how behaves). + /// + /// + /// To select an option, set to its index in . + /// Out-of-range values throw . + /// + /// + public int? SelectedIndex + { + get => SelectedIndices.Count > 0 ? SelectedIndices [0] : null; + set + { + if (value is null) + { + if (!AllowEmpty) + { + return; + } + + _value = default; + ApplySelectedIndices ([]); + + return; + } + + if (Options is null || value < 0 || value >= Options.Count) + { + throw new ArgumentOutOfRangeException (nameof (value)); + } + + // Sync indices first so SelectedIndex can select an option whose Data equals the current + // _value (e.g. selecting option 0 in a value-type selector where _value is already + // default(int)=0 — Value setter would short-circuit on equality, leaving SelectedIndex null). + T? newValue = Options [value.Value].Data; + T? current = _value; + bool valueChanged = !EqualityComparer.Default.Equals (current, newValue); + + if (valueChanged && RaiseValueChanging (current, newValue)) + { + return; + } + + _value = newValue; + ApplySelectedIndices ([value.Value]); + + if (valueChanged) + { + RaiseValueChanged (current, _value); + } + } + } + + /// + protected override void OnSelectionChanged () + { + T? previous = _value; + T? newValue = SelectedIndices.Count > 0 ? Options [SelectedIndices [0]].Data : default; + + if (EqualityComparer.Default.Equals (previous, newValue)) + { + return; + } + + _value = newValue; + RaiseValueChanged (previous, newValue); + } + + /// + /// Loads demo data suitable for a designer preview: a single-select + /// of T-shirt sizes (XS through XXL) with "M" preselected. Only populated when + /// is ; for any other type, the view is left untouched + /// and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "T-Shirt Size"; + AssignHotKeys = true; + ShowLegends = true; + + string [] sizes = ["XS", "S", "M", "L", "XL", "XXL"]; + + Options = sizes.Select ( + s => new LinearRangeOption (s, (Rune)s [0], (T)(object)s)) + .ToList (); + + Value = (T)(object)"M"; + + return true; + } +} diff --git a/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs b/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs index 22cfe681e0..2a188ab63b 100644 --- a/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs +++ b/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs @@ -8,36 +8,34 @@ public class LinearRangeFluentTests (ITestOutputHelper outputHelper) : TestsAllD [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_CanCreateAndRender (string d) + public void LinearSelector_CanCreateAndRender (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30, 40, 50]) + new LinearSelector ([0, 10, 20, 30, 40, 50]) { X = 2, - Y = 2, - Type = LinearRangeType.Single + Y = 2 }) - .Focus> () + .Focus> () .WaitIteration () - .ScreenShot ("LinearRange initial render", _out) + .ScreenShot ("LinearSelector initial render", _out) .Stop (); } [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_CanNavigateWithArrowKeys (string d) + public void LinearSelector_CanNavigateWithArrowKeys (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30]) + new LinearSelector ([0, 10, 20, 30]) { X = 2, Y = 2, - Type = LinearRangeType.Single, AllowEmpty = false }) - .Focus> () + .Focus> () .WaitIteration () .ScreenShot ("Initial state", _out) .KeyDown (Key.CursorRight) @@ -51,50 +49,41 @@ public void LinearRange_CanNavigateWithArrowKeys (string d) [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_TypeChange_TriggersEvents (string d) + public void LinearRange_RangeKindChange_TriggersValueChange (string d) { LinearRange linearRange = new ([0, 10, 20, 30]) { X = 2, Y = 2, - Type = LinearRangeType.Single + RangeKind = LinearRangeSpanKind.Closed }; + linearRange.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 0, 30, 0, 3); - var changingEventRaised = false; - var changedEventRaised = false; + var changedRaised = false; - linearRange.TypeChanging += (_, args) => + linearRange.ValueChanged += (_, args) => { - changingEventRaised = true; - Assert.Equal (LinearRangeType.Single, args.CurrentValue); - Assert.Equal (LinearRangeType.Range, args.NewValue); + changedRaised = true; + Assert.Equal (LinearRangeSpanKind.LeftBounded, args.NewValue.Kind); }; - linearRange.TypeChanged += (_, args) => - { - changedEventRaised = true; - Assert.Equal (LinearRangeType.Single, args.OldValue); - Assert.Equal (LinearRangeType.Range, args.NewValue); - }; - - // Change the type before adding to window - linearRange.Type = LinearRangeType.Range; + // Migrate from Closed -> LeftBounded; the End is preserved. + linearRange.RangeKind = LinearRangeSpanKind.LeftBounded; using AppTestHelper c = With.A (30, 10, d, _out) .Add (linearRange) .Focus> () .WaitIteration () - .ScreenShot ("After type change to Range", _out) + .ScreenShot ("After RangeKind change to LeftBounded", _out) .Stop (); - Assert.True (changingEventRaised); - Assert.True (changedEventRaised); - Assert.Equal (LinearRangeType.Range, linearRange.Type); + Assert.True (changedRaised); + Assert.Equal (LinearRangeSpanKind.LeftBounded, linearRange.RangeKind); } [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_RangeType_CanSelectRange (string d) + public void LinearRange_Closed_CanSelectRange (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( @@ -102,7 +91,7 @@ public void LinearRange_RangeType_CanSelectRange (string d) { X = 2, Y = 2, - Type = LinearRangeType.Range, + RangeKind = LinearRangeSpanKind.Closed, AllowEmpty = false }) .Focus> () @@ -121,18 +110,17 @@ public void LinearRange_RangeType_CanSelectRange (string d) [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_VerticalOrientation_Renders (string d) + public void LinearSelector_VerticalOrientation_Renders (string d) { using AppTestHelper c = With.A (10, 15, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30]) + new LinearSelector ([0, 10, 20, 30]) { X = 2, Y = 2, - Orientation = Orientation.Vertical, - Type = LinearRangeType.Single + Orientation = Orientation.Vertical }) - .Focus> () + .Focus> () .WaitIteration () .ScreenShot ("Vertical orientation", _out) .KeyDown (Key.CursorDown) diff --git a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs index 6400e21ad0..cf24139fcf 100644 --- a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs +++ b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs @@ -72,7 +72,7 @@ public void HardCoded_Default_Theme_Uses_Fully_Populated_Cache_Values () ); Assert.Equal ( CursorStyle.BlinkingBlock, - (CursorStyle)defaultTheme ["LinearRange.DefaultCursorStyle"].PropertyValue! + (CursorStyle)defaultTheme ["LinearRangeDefaults.DefaultCursorStyle"].PropertyValue! ); } finally diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs new file mode 100644 index 0000000000..919d993a42 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs @@ -0,0 +1,175 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearMultiSelectorTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearMultiSelector ms = new (); + + Assert.NotNull (ms); + Assert.NotNull (ms.Value); + Assert.Empty (ms.Value!); + } + + // Copilot + [Fact] + public void Value_Setter_Selects_Matching_Options () + { + LinearMultiSelector ms = new (["A", "B", "C"]); + + ms.Value = ["A", "C"]; + + Assert.Equal (2, ms.Value!.Count); + Assert.Equal (2, ms.SelectedIndices.Count); + Assert.Contains (0, ms.SelectedIndices); + Assert.Contains (2, ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Null_Treated_As_Empty () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A"] }; + + ms.Value = null; + + Assert.NotNull (ms.Value); + Assert.Empty (ms.Value!); + Assert.Empty (ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_SequenceEqual_Does_Not_Raise_Events () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A", "B"] }; + var changedCount = 0; + ms.ValueChanged += (_, _) => changedCount++; + + // Different list instance, same elements/order. + ms.Value = ["A", "B"]; + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_Defensive_Copy_Of_Input () + { + LinearMultiSelector ms = new (["A", "B", "C"]); + List mutable = ["A"]; + + ms.Value = mutable; + + // Mutate caller's list after assignment. + mutable.Add ("B"); + + Assert.Single (ms.Value!); + Assert.Equal ("A", ms.Value! [0]); + } + + // Copilot + [Fact] + public void Value_Getter_Never_Returns_Null () + { + LinearMultiSelector ms = new (["A"]); + + Assert.NotNull (ms.Value); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearMultiSelector ms = new (["A", "B"]); + ms.ValueChanging += (_, args) => args.Handled = true; + + ms.Value = ["A"]; + + Assert.Empty (ms.Value!); + Assert.Empty (ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Multi_Selection_Builds_Sorted_Value () + { + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + + // Activate index 2 then index 0. + ms.FocusedOption = 2; + ms.InvokeCommand (Command.Activate); + + ms.FocusedOption = 0; + ms.InvokeCommand (Command.Activate); + + // Value is built in option-order, not selection-order. + Assert.Equal (2, ms.Value!.Count); + Assert.Equal ("A", ms.Value! [0]); + Assert.Equal ("C", ms.Value! [1]); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_List () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A"] }; + IValue ivalue = ms; + + object? boxed = ivalue.GetValue (); + + IReadOnlyList? list = boxed as IReadOnlyList; + Assert.NotNull (list); + Assert.Single (list!); + Assert.Equal ("A", list! [0]); + } + + [Fact] + public void EnableForDesign_String_Populates_Days_With_Weekdays_Selected () + { + // Copilot + LinearMultiSelector ms = new (); + + bool ok = ms.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (7, ms.Options.Count); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], ms.Options.Select (o => o.Legend)); + Assert.NotNull (ms.Value); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri"], ms.Value!); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearMultiSelector ms = new (); + + bool ok = ms.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (ms.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearMultiSelector_Activator_CreateInstance_And_EnableForDesign_Populates () + { + Type type = typeof (LinearMultiSelector); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearMultiSelector ms = (LinearMultiSelector)view; + Assert.Equal (7, ms.Options.Count); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri"], ms.Value!); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs new file mode 100644 index 0000000000..d24724059a --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs @@ -0,0 +1,90 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for . +/// +public class LinearMultiSelectorVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Multiple_Set_Options () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { Value = ["A", "C"], AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Indexes 0 and 2 set; index 1 not set. + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + █─●─█ + A B C + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Keyboard_Space_Toggles_Selection () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + ms.SetFocus (); + + // Toggle index 0 on, then move and toggle index 2 on. + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["A"], ms.Value); + + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["A", "C"], ms.Value); + + // Move back and toggle off. + app.InjectKey (new Key (KeyCode.CursorLeft)); + app.InjectKey (new Key (KeyCode.CursorLeft)); + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["C"], ms.Value); + } + + // Copilot + [Fact] + public void Mouse_Click_Toggles_Selection () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (ms.TryGetPositionByOption (1, out (int x, int y) p1)); + Point screen = ms.ViewportToScreen (new Point (p1.x, p1.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = screen }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = screen }); + + Assert.Equal (["B"], ms.Value); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs new file mode 100644 index 0000000000..1f9576e7a4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs @@ -0,0 +1,205 @@ +using System.Text; +using UnitTests; + +namespace ViewsTests; + +/// +/// Tests for the Cancellable Workflow Pattern (CWP) properties on . +/// +public class LinearRangeCWPTests : TestDriverBase +{ + [Fact] + public void LegendsOrientation_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = Orientation.Horizontal; + var newValue = Orientation.Vertical; + + sel.LegendsOrientationChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.LegendsOrientationChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.LegendsOrientation = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.LegendsOrientation); + } + + [Fact] + public void MinimumInnerSpacing_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = 1; + var newValue = 5; + + sel.MinimumInnerSpacingChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.MinimumInnerSpacingChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.MinimumInnerSpacing = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.MinimumInnerSpacing); + } + + [Fact] + public void ShowEndSpacing_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = false; + var newValue = true; + + sel.ShowEndSpacingChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowEndSpacingChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowEndSpacing = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.ShowEndSpacing); + } + + [Fact] + public void ShowLegends_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = true; + var newValue = false; + + sel.ShowLegendsChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowLegendsChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowLegends = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.ShowLegends); + } + + [Fact] + public void UseMinimumSize_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = false; + var newValue = true; + + sel.UseMinimumSizeChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.UseMinimumSizeChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.UseMinimumSize = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.UseMinimumSize); + } + + // Copilot + [Fact] + public void Command_Activate_Calls_SetFocusedOption () + { + LinearSelector sel = new (); + + sel.Options = + [ + new LinearRangeOption ("A", new Rune ('a'), 1), + new LinearRangeOption ("B", new Rune ('b'), 2), + new LinearRangeOption ("C", new Rune ('c'), 3) + ]; + + sel.FocusedOption = 1; + + bool? result = sel.InvokeCommand (Command.Activate); + + Assert.False (result); + Assert.Contains (1, sel.SelectedIndices); + + sel.Dispose (); + } + + // Copilot + [Fact] + public void Command_Accept_Calls_SetFocusedOption () + { + LinearSelector sel = new (); + + sel.Options = + [ + new LinearRangeOption ("A", new Rune ('a'), 1), + new LinearRangeOption ("B", new Rune ('b'), 2), + new LinearRangeOption ("C", new Rune ('c'), 3) + ]; + + sel.FocusedOption = 2; + + sel.InvokeCommand (Command.Accept); + + Assert.Contains (2, sel.SelectedIndices); + + sel.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs similarity index 64% rename from Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs rename to Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs index 47e330473d..fe195f98ed 100644 --- a/Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs @@ -5,17 +5,17 @@ namespace ViewsTests; /// -/// Tests for static property. +/// Tests for static property. /// public class LinearRangeDefaultKeyBindingsTests { [Fact] - public void LinearRange_DefaultKeyBindings_IsNotNull () => Assert.NotNull (LinearRange.DefaultKeyBindings); + public void LinearRange_DefaultKeyBindings_IsNotNull () => Assert.NotNull (LinearSelector.DefaultKeyBindings); [Fact] public void LinearRange_DefaultKeyBindings_AllKeyStringsParseable () { - foreach ((Command command, PlatformKeyBinding platformBinding) in LinearRange.DefaultKeyBindings!) + foreach ((Command command, PlatformKeyBinding platformBinding) in LinearSelector.DefaultKeyBindings!) { Key [] [] allKeyArrays = [platformBinding.All ?? [], platformBinding.Windows ?? [], platformBinding.Linux ?? [], platformBinding.Macos ?? []]; @@ -32,7 +32,7 @@ public void LinearRange_DefaultKeyBindings_AllKeyStringsParseable () [Fact] public void LinearRange_DefaultKeyBindings_AllCommandNamesParseable () { - foreach (Command command in LinearRange.DefaultKeyBindings!.Keys) + foreach (Command command in LinearSelector.DefaultKeyBindings!.Keys) { Assert.True (Enum.IsDefined (command), $"Command name '{command}' should parse to a Command enum value."); } @@ -41,8 +41,11 @@ public void LinearRange_DefaultKeyBindings_AllCommandNamesParseable () [Fact] public void LinearRange_DefaultKeyBindings_DoesNotHaveConfigurationPropertyAttribute () { + // DefaultKeyBindings is declared on the abstract base LinearRangeViewBase. PropertyInfo? property = - typeof (LinearRange).GetProperty (nameof (LinearRange.DefaultKeyBindings), BindingFlags.Public | BindingFlags.Static); + typeof (LinearRangeViewBase).GetProperty ( + nameof (LinearRangeViewBase.DefaultKeyBindings), + BindingFlags.Public | BindingFlags.Static); Assert.NotNull (property); @@ -55,5 +58,5 @@ public void LinearRange_DefaultKeyBindings_DoesNotHaveConfigurationPropertyAttri [InlineData (Command.Accept)] [InlineData (Command.Activate)] public void LinearRange_DefaultKeyBindings_ContainsUniqueCommands (Command command) => - Assert.True (LinearRange.DefaultKeyBindings!.ContainsKey (command)); + Assert.True (LinearSelector.DefaultKeyBindings!.ContainsKey (command)); } diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs new file mode 100644 index 0000000000..0845343c08 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs @@ -0,0 +1,119 @@ +using System.Text; +using UnitTests; + +namespace ViewsTests; + +public class LinearRangeOptionTests : TestDriverBase +{ + [Fact] + public void LinearRange_Option_Default_Constructor () + { + LinearRangeOption o = new (); + Assert.Null (o.Legend); + Assert.Equal (default (Rune), o.LegendAbbr); + Assert.Equal (0, o.Data); + } + + [Fact] + public void LinearRange_Option_Values_Constructor () + { + LinearRangeOption o = new ("1 thousand", new Rune ('y'), 1000); + Assert.Equal ("1 thousand", o.Legend); + Assert.Equal (new Rune ('y'), o.LegendAbbr); + Assert.Equal (1000, o.Data); + } + + [Fact] + public void LinearRangeOption_ToString_WhenEmpty () + { + LinearRangeOption sliderOption = new (); + Assert.Equal ("{Legend=, LegendAbbr=\0, Data=}", sliderOption.ToString ()); + } + + [Fact] + public void LinearRangeOption_ToString_WhenPopulated_WithInt () + { + LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = 1 }; + + Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data=1}", sliderOption.ToString ()); + } + + [Fact] + public void LinearRangeOption_ToString_WhenPopulated_WithSizeF () + { + LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = new SizeF (32, 11) }; + + Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data={Width=32, Height=11}}", sliderOption.ToString ()); + } + + [Fact] + public void OnChanged_Should_Raise_ChangedEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.Changed += (sender, args) => eventRaised = true; + + sliderOption.OnChanged (true); + + Assert.True (eventRaised); + } + + [Fact] + public void OnSet_Should_Raise_SetEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.Set += (sender, args) => eventRaised = true; + + sliderOption.OnSet (); + + Assert.True (eventRaised); + } + + [Fact] + public void OnUnSet_Should_Raise_UnSetEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.UnSet += (sender, args) => eventRaised = true; + + sliderOption.OnUnSet (); + + Assert.True (eventRaised); + } +} + +public class LinearRangeEventArgsTests : TestDriverBase +{ + [Fact] + public void Constructor_Sets_Cancel_Default_To_False () + { + Dictionary> options = new (); + var focused = 42; + + LinearRangeEventArgs sliderEventArgs = new (options, focused); + + Assert.False (sliderEventArgs.Cancel); + } + + [Fact] + public void Constructor_Sets_Focused () + { + Dictionary> options = new (); + var focused = 42; + + LinearRangeEventArgs sliderEventArgs = new (options, focused); + + Assert.Equal (focused, sliderEventArgs.Focused); + } + + [Fact] + public void Constructor_Sets_Options () + { + Dictionary> options = new (); + + LinearRangeEventArgs sliderEventArgs = new (options); + + Assert.Equal (options, sliderEventArgs.Options); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs new file mode 100644 index 0000000000..79f767880e --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs @@ -0,0 +1,271 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearRangeRangeTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearRange r = new (); + + Assert.NotNull (r); + Assert.Equal (LinearRangeSpanKind.Closed, r.RangeKind); + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + } + + // Copilot + [Fact] + public void Value_Setter_Closed_Selects_Both_Bounds () + { + LinearRange r = new ([10, 20, 30, 40]); + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (40, r.Value.End); + Assert.Contains (1, r.SelectedIndices); + Assert.Contains (3, r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Resolves_Indices_From_Data_When_NotProvided () + { + LinearRange r = new ([10, 20, 30, 40]); + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, -1, -1); + + // The setter should resolve indices via IndexOfData. + Assert.Contains (1, r.SelectedIndices); + Assert.Contains (3, r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_LeftBounded_Selects_End_Only () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 20, -1, 1); + + Assert.Single (r.SelectedIndices); + Assert.Equal (1, r.SelectedIndices [0]); + Assert.Equal (20, r.Value.End); + } + + // Copilot + [Fact] + public void Value_Setter_RightBounded_Selects_Start_Only () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.RightBounded }; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.RightBounded, 20, default, 1, -1); + + Assert.Single (r.SelectedIndices); + Assert.Equal (1, r.SelectedIndices [0]); + Assert.Equal (20, r.Value.Start); + } + + // Copilot + [Fact] + public void Value_Setter_None_Clears () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + r.Value = LinearRangeSpan.Empty; + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Same_Value_Does_Not_Raise_Events () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + var changedCount = 0; + r.ValueChanged += (_, _) => changedCount++; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearRange r = new ([10, 20, 30]); + r.ValueChanging += (_, args) => args.Handled = true; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void RangeKind_Closed_To_LeftBounded_Drops_Start () + { + LinearRange r = new ([10, 20, 30, 40]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + r.RangeKind = LinearRangeSpanKind.LeftBounded; + + Assert.Equal (LinearRangeSpanKind.LeftBounded, r.Value.Kind); + Assert.Equal (40, r.Value.End); + Assert.Equal (3, r.Value.EndIndex); + Assert.Equal (default, r.Value.Start); + Assert.Equal (-1, r.Value.StartIndex); + } + + // Copilot + [Fact] + public void RangeKind_Closed_To_RightBounded_Drops_End () + { + LinearRange r = new ([10, 20, 30, 40]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + r.RangeKind = LinearRangeSpanKind.RightBounded; + + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (1, r.Value.StartIndex); + Assert.Equal (default, r.Value.End); + Assert.Equal (-1, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void RangeKind_LeftBounded_To_RightBounded_Promotes_End_To_Start () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 20, -1, 1); + + r.RangeKind = LinearRangeSpanKind.RightBounded; + + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (1, r.Value.StartIndex); + } + + // Copilot + [Fact] + public void RangeKind_LeftBounded_To_Closed_Collapses_End_To_Both () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 30, -1, 2); + + r.RangeKind = LinearRangeSpanKind.Closed; + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (30, r.Value.Start); + Assert.Equal (30, r.Value.End); + } + + // Copilot + [Fact] + public void RangeKind_To_None_Clears () + { + LinearRange r = new ([10, 20]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + + r.RangeKind = LinearRangeSpanKind.None; + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void LinearRangeSpan_Equality_Is_ByValue () + { + LinearRangeSpan a = new (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + LinearRangeSpan b = new (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + + Assert.Equal (a, b); + Assert.True (a == b); + Assert.Equal (a.GetHashCode (), b.GetHashCode ()); + } + + // Copilot + [Fact] + public void LinearRangeSpan_Empty_Has_None_Kind () + { + LinearRangeSpan empty = LinearRangeSpan.Empty; + + Assert.Equal (LinearRangeSpanKind.None, empty.Kind); + Assert.Equal (-1, empty.StartIndex); + Assert.Equal (-1, empty.EndIndex); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_Span () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + IValue ivalue = r; + + object? boxed = ivalue.GetValue (); + + Assert.IsType> (boxed); + Assert.Equal (r.Value, (LinearRangeSpan)boxed!); + } + + [Fact] + public void EnableForDesign_String_Populates_WorkHours_Closed_Range () + { + // Copilot + LinearRange r = new (); + + bool ok = r.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (11, r.Options.Count); + Assert.Equal (LinearRangeSpanKind.Closed, r.RangeKind); + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal ("9 AM", r.Value.Start); + Assert.Equal ("5 PM", r.Value.End); + Assert.Equal (1, r.Value.StartIndex); + Assert.Equal (9, r.Value.EndIndex); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearRange r = new (); + + bool ok = r.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (r.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearRange_Activator_CreateInstance_And_EnableForDesign_Populates () + { + Type type = typeof (LinearRange); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearRange r = (LinearRange)view; + Assert.Equal (11, r.Options.Count); + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal ("9 AM", r.Value.Start); + Assert.Equal ("5 PM", r.Value.End); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs new file mode 100644 index 0000000000..c6b872a23c --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs @@ -0,0 +1,279 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Shared base behavior tests, exercised through +/// (the simplest concrete subclass). +/// +public class LinearRangeViewBaseTests : TestDriverBase +{ + [Fact] + public void MovePlus_Should_MoveFocusRight_When_OptionIsAvailable () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + bool result = sel.MovePlus (); + + Assert.True (result); + Assert.Equal (1, sel.FocusedOption); + } + + [Fact] + public void MovePlus_Should_NotMoveFocusRight_When_AtEnd () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.FocusedOption = 3; + + bool result = sel.MovePlus (); + + Assert.False (result); + Assert.Equal (3, sel.FocusedOption); + } + + [Fact] + public void OnOptionFocused_Event_Cancelled () + { + LinearSelector sel = new ([1, 2, 3]); + var eventRaised = false; + sel.OptionFocused += (sender, args) => eventRaised = true; + var newFocusedOption = 1; + + LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption) { Cancel = false }; + Assert.Equal (0, sel.FocusedOption); + + sel.OnOptionFocused (newFocusedOption, args); + + Assert.True (eventRaised); + Assert.Equal (newFocusedOption, sel.FocusedOption); + + args = new LinearRangeEventArgs (new Dictionary> (), newFocusedOption) { Cancel = true }; + + sel.OnOptionFocused (2, args); + + Assert.True (eventRaised); + Assert.Equal (newFocusedOption, sel.FocusedOption); + } + + [Fact] + public void OnOptionFocused_Event_Raised () + { + LinearSelector sel = new ([1, 2, 3]); + var eventRaised = false; + sel.OptionFocused += (sender, args) => eventRaised = true; + var newFocusedOption = 1; + LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption); + + sel.OnOptionFocused (newFocusedOption, args); + + Assert.True (eventRaised); + } + + [Fact] + public void Set_Should_Not_Clear_When_EmptyNotAllowed () + { + LinearSelector sel = new ([1, 2, 3, 4]) { AllowEmpty = false }; + + Assert.NotEmpty (sel.SelectedIndices); + + // Re-activating the same focused option must not clear it when AllowEmpty=false. + sel.InvokeCommand (Command.Activate); + + Assert.NotEmpty (sel.SelectedIndices); + } + + [Fact] + public void Set_Should_SetFocusedOption () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.FocusedOption = 2; + bool result = sel.InvokeCommand (Command.Activate) ?? false; + + Assert.Equal (2, sel.FocusedOption); + Assert.Single (sel.SelectedIndices); + } + + [Fact] + public void TryGetOptionByPosition_InvalidPosition_Failure () + { + LinearSelector sel = new ([1, 2, 3]); + var x = 10; + var y = 10; + var threshold = 2; + int expectedOption = -1; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.False (result); + Assert.Equal (expectedOption, option); + } + + [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) + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.True (result); + Assert.Equal (expectedData, sel.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) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.Orientation = Orientation.Vertical; + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.True (result); + Assert.Equal (expectedData, sel.Options [option].Data); + } + + [Fact] + public void TryGetPositionByOption_InvalidOption_Failure () + { + LinearSelector sel = new ([1, 2, 3]); + int option = -1; + (int, int) expectedPosition = (-1, -1); + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + Assert.False (result); + Assert.Equal (expectedPosition, position); + } + + [Theory] + [InlineData (0, 0, 0)] + [InlineData (1, 3, 0)] + [InlineData (3, 9, 0)] + public void TryGetPositionByOption_ValidOptionHorizontal_Success (int option, int expectedX, int expectedY) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + 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) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.Orientation = Orientation.Vertical; + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + Assert.True (result); + Assert.Equal (expectedX, position.x); + Assert.Equal (expectedY, position.y); + } + + [Fact] + private void DimAuto_Both_Respects_SuperView_ContentSize () + { + View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (6, 3), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + [Fact] + private void DimAuto_Height_Respects_SuperView_ContentSize () + { + View view = new () { Width = 10, Height = Dim.Fill () }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical, Width = 10 }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (10, 3), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + [Fact] + private void DimAuto_Width_Respects_SuperView_ContentSize () + { + View view = new () { Width = Dim.Fill (), Height = 10 }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical, Height = 10 }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (6, 10), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + // https://github.com/gui-cs/Terminal.Gui/issues/3099 + [Fact] + private void One_Option_Does_Not_Throw () + { + LinearSelector sel = new (); + sel.BeginInit (); + sel.EndInit (); + + sel.Options = [new LinearRangeOption ()]; + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs new file mode 100644 index 0000000000..00c9cdd03a --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs @@ -0,0 +1,241 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for (range-only). +/// +public class LinearRangeVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Closed_Range () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 2, 3, 1, 2); + (runnable as View)?.Add (r); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Indexes 1..2 selected: '●' (option), '─' (space), '█' (set), '░' (range). + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + ●─█░█─● + 1 2 3 4 + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_End_Of_Range () + { + // This is the regression test for the user-reported bug: + // "Mouse dragging of the end of a range is not adjusting the values." + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true }; + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (1, out (int x, int y) p1)); + Assert.True (r.TryGetPositionByOption (4, out (int x, int y) p4)); + Point sStart = r.ViewportToScreen (new Point (p1.x, p1.y)); + Point sEnd = r.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at index 1 (the start of the range). + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sStart }); + + // Drag to index 4 — the end of the range. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sEnd }); + + // Release. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sEnd }); + + // Range should span [2..5] = data values at indexes 1..4. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (2, r.Value.Start); + Assert.Equal (5, r.Value.End); + } + + // Copilot + [Fact] + public void Keyboard_Extends_Range () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true }; + (runnable as View)?.Add (r); + app.Begin (runnable); + r.SetFocus (); + + // Activate at index 0 (start of range). + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (1, r.Value.Start); + + // Ctrl+Right extends the range. + app.InjectKey (new Key (KeyCode.CursorRight | KeyCode.CtrlMask)); + app.InjectKey (new Key (KeyCode.CursorRight | KeyCode.CtrlMask)); + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (1, r.Value.Start); + Assert.Equal (3, r.Value.End); + } + + // Copilot + [Fact] + public void Mouse_Press_On_Left_End_Of_Closed_Range_Preserves_Right_End () + { + // Bug: pressing on the left end of an existing Closed range causes the right end + // to reset/collapse depending on the previous _lastFocusedOption. + // + // Setup: Closed range covering all five options [0..4] (data 1..5). + // Click at the right end first to set _lastFocusedOption to the right. + // Then press at the left end. The range should still terminate at option 4. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3 }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 1, 5, 0, 4); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + // First, force focus on the right end so _lastFocusedOption=4 when we press the left. + r.OnOptionFocused (4, new LinearRangeEventArgs (new (), 4)); + + Assert.True (r.TryGetPositionByOption (0, out (int x, int y) pLeft)); + Point sLeft = r.ViewportToScreen (new Point (pLeft.x, pLeft.y)); + + // Press at left end (option 0). + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sLeft }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sLeft }); + + // The range should still cover [option 0 .. option 4] = data 1..5. The right end must + // not have collapsed. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (1, r.Value.Start); + Assert.Equal (5, r.Value.End); + } + + // Copilot + [Fact] + public void Mouse_Press_Inside_Closed_Range_Does_Not_Collapse_Range () + { + // Bug: pressing inside an existing Closed range causes one end to reset (collapse to a point). + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3 }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 2, 5, 1, 4); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Force the right end (option 4) to be the most recently focused. + r.OnOptionFocused (4, new LinearRangeEventArgs (new (), 4)); + + // Press at option 2 (inside the [1..4] range). + Assert.True (r.TryGetPositionByOption (2, out (int x, int y) pMid)); + Point sMid = r.ViewportToScreen (new Point (pMid.x, pMid.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sMid }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sMid }); + + // Range should remain closed and bracket option 2 — both ends should still exist. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.NotEqual (r.Value.StartIndex, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_End_Of_LeftBounded_Range () + { + // Bug: for LeftBounded (RangeKind=LeftBounded), dragging the single end should follow the mouse. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3, RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 3, -1, 2); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (0, out (int x, int y) p0)); + Assert.True (r.TryGetPositionByOption (4, out (int x, int y) p4)); + Point s0 = r.ViewportToScreen (new Point (p0.x, p0.y)); + Point s4 = r.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at option 0 and drag to option 4. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s0 }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s4 }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s4 }); + + // The single bounded end should now be option 4 (data 5). + Assert.Equal (LinearRangeSpanKind.LeftBounded, r.Value.Kind); + Assert.Equal (5, r.Value.End); + Assert.Equal (4, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_Start_Of_RightBounded_Range () + { + // Bug: for RightBounded (RangeKind=RightBounded), dragging the single start should follow the mouse, + // including through positions that snap via threshold. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3, RangeKind = LinearRangeSpanKind.RightBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.RightBounded, 2, default, 1, -1); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (1, out (int x, int y) p1)); + Assert.True (r.TryGetPositionByOption (3, out (int x, int y) p3)); + Point s1 = r.ViewportToScreen (new Point (p1.x, p1.y)); + Point s3 = r.ViewportToScreen (new Point (p3.x, p3.y)); + + // Press at option 1, drag through intermediate positions to option 3. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s1 }); + + // Step through every viewport-x between s1.X and s3.X to simulate a continuous mouse drag. + for (int x = s1.X + 1; x <= s3.X; x++) + { + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = new Point (x, s1.Y) }); + } + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s3 }); + + // Start should now be at option 3 (data 4); right end stays unbounded. + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (4, r.Value.Start); + Assert.Equal (3, r.Value.StartIndex); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs new file mode 100644 index 0000000000..08b6a2dc85 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs @@ -0,0 +1,365 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearSelectorTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearSelector sel = new (); + + Assert.NotNull (sel); + Assert.NotNull (sel.Options); + Assert.Empty (sel.Options); + Assert.Equal (Orientation.Horizontal, sel.Orientation); + Assert.False (sel.AllowEmpty); + Assert.True (sel.ShowLegends); + Assert.False (sel.ShowEndSpacing); + Assert.Equal (1, sel.MinimumInnerSpacing); + Assert.True (sel.Width is DimAuto); + Assert.True (sel.Height is DimAuto); + Assert.Equal (0, sel.FocusedOption); + Assert.Equal (default, sel.Value); + } + + [Fact] + public void Constructor_With_Options () + { + List options = [1, 2, 3]; + + LinearSelector sel = new (options); + sel.SetRelativeLayout (new Size (100, 100)); + + // 1 2 3 + Assert.Equal (1, sel.MinimumInnerSpacing); + Assert.Equal (new Size (5, 2), sel.GetContentSize ()); + Assert.Equal (new Size (5, 2), sel.Frame.Size); + Assert.Equal (options.Count, sel.Options.Count); + } + + // Copilot + [Fact] + public void Value_Setter_Selects_Matching_Option () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 20; + + Assert.Equal (20, sel.Value); + Assert.Single (sel.SelectedIndices); + Assert.Equal (1, sel.SelectedIndices [0]); + } + + // Copilot + [Fact] + public void Value_Setter_Null_Clears_Selection () + { + LinearSelector sel = new ([10, 20, 30]); + sel.Value = 20; + + sel.Value = default; + + Assert.Equal (default, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Unmatched_Clears_Indices () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 99; + + Assert.Equal (99, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Same_Value_Does_Not_Raise_Events () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 20 }; + var changedCount = 0; + sel.ValueChanged += (_, _) => changedCount++; + + sel.Value = 20; + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_Raises_ValueChanging_And_ValueChanged () + { + LinearSelector sel = new ([10, 20, 30]); + var changingRaised = false; + var changedRaised = false; + + sel.ValueChanging += (_, args) => + { + changingRaised = true; + Assert.Equal (default, args.CurrentValue); + Assert.Equal (20, args.NewValue); + }; + + sel.ValueChanged += (_, args) => + { + changedRaised = true; + Assert.Equal (default, args.OldValue); + Assert.Equal (20, args.NewValue); + }; + + sel.Value = 20; + + Assert.True (changingRaised); + Assert.True (changedRaised); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.ValueChanging += (_, args) => args.Handled = true; + + sel.Value = 20; + + Assert.Equal (default, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Selection_Syncs_Value () + { + LinearSelector sel = new ([10, 20, 30]); + sel.FocusedOption = 1; + + sel.InvokeCommand (Command.Activate); + + Assert.Equal (20, sel.Value); + Assert.Single (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Selection_Raises_ValueChanged_And_ValueChangedUntyped () + { + LinearSelector sel = new ([10, 20, 30]); + var typedRaised = false; + var untypedRaised = false; + + sel.ValueChanged += (_, args) => + { + typedRaised = true; + Assert.Equal (default, args.OldValue); + Assert.Equal (20, args.NewValue); + }; + + IValue ivalue = sel; + ivalue.ValueChangedUntyped += (_, args) => + { + untypedRaised = true; + Assert.Equal (20, args.NewValue); + }; + + sel.FocusedOption = 1; + sel.InvokeCommand (Command.Activate); + + Assert.True (typedRaised); + Assert.True (untypedRaised); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_Value () + { + LinearSelector sel = new ([10, 20, 30]); + sel.Value = 30; + + IValue ivalue = sel; + + Assert.Equal (30, ivalue.GetValue ()); + } + + // Copilot + [Fact] + public void Options_Replacement_Drops_Stale_Indices () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 30 }; + + Assert.Single (sel.SelectedIndices); + + sel.Options = [new LinearRangeOption { Data = 1 }]; + + // Index 2 from previous selection no longer exists. + Assert.Empty (sel.SelectedIndices); + } + + [Fact] + public void EnableForDesign_String_Populates_TShirt_Sizes_With_M_Selected () + { + // Copilot + LinearSelector sel = new (); + + bool ok = sel.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (6, sel.Options.Count); + Assert.Equal (["XS", "S", "M", "L", "XL", "XXL"], sel.Options.Select (o => o.Legend)); + Assert.Equal ("M", sel.Value); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearSelector sel = new (); + + bool ok = sel.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (sel.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearSelector_Activator_CreateInstance_And_EnableForDesign_Populates () + { + // AllViewsTester reflects over public, non-abstract, non-generic View subclasses and + // instantiates them via Activator.CreateInstance, then calls IDesignable.EnableForDesign. + // The non-generic LinearSelector exists so this discovery path works. + Type type = typeof (LinearSelector); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearSelector sel = (LinearSelector)view; + Assert.Equal (6, sel.Options.Count); + Assert.Equal ("M", sel.Value); + } + + // Copilot - Sonnet 4.5 - SelectedIndex coverage for "no selection" representation across + // value-type and reference-type T (addresses bot review comment 3196703096). + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection () + { + LinearSelector sel = new ([10, 20, 30]); + + // Constructor does not auto-select; SelectedIndex is null until something is selected. + Assert.Null (sel.SelectedIndex); + } + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection_For_ValueType_T () + { + LinearSelector sel = new ([0, 1, 2]) { AllowEmpty = true, Value = 1 }; + Assert.Equal (1, sel.SelectedIndex); + + sel.SelectedIndex = null; + + // Value collapses to default(int)=0, which is also a legitimate option: + // SelectedIndex is the only unambiguous "no selection" surface. + Assert.Null (sel.SelectedIndex); + Assert.Equal (0, sel.Value); + } + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection_For_ReferenceType_T () + { + LinearSelector sel = new (["a", "b", "c"]) { AllowEmpty = true }; + sel.Value = null; + + Assert.Null (sel.SelectedIndex); + Assert.Null (sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_Selects_Option_By_Index () + { + LinearSelector sel = new ([10, 20, 30]); + sel.SelectedIndex = 2; + + Assert.Equal (2, sel.SelectedIndex); + Assert.Equal (30, sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_Null_Clears_Selection_When_AllowEmpty () + { + LinearSelector sel = new ([10, 20, 30]) { AllowEmpty = true, Value = 20 }; + Assert.Equal (1, sel.SelectedIndex); + + sel.SelectedIndex = null; + + Assert.Null (sel.SelectedIndex); + } + + [Fact] + public void SelectedIndex_Setter_Null_Ignored_When_AllowEmpty_False () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 20 }; + Assert.False (sel.AllowEmpty); + + sel.SelectedIndex = null; + + // Selection unchanged because AllowEmpty=false. + Assert.Equal (1, sel.SelectedIndex); + Assert.Equal (20, sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_OutOfRange_Throws () + { + LinearSelector sel = new ([10, 20, 30]); + + Assert.Throws (() => sel.SelectedIndex = -1); + Assert.Throws (() => sel.SelectedIndex = 3); + } + + [Fact] + public void SelectedIndex_Disambiguates_Default_Value_From_Empty_Selection () + { + // Tests the core motivation for SelectedIndex: for value types, Value=default(T) + // can mean either "option with default value selected" or "no selection". + // Note: assigning Value=0 to a fresh selector is a no-op because the equality guard + // sees the field already at default(int)=0. SelectedIndex is the only way to + // unambiguously select option 0 (or distinguish it from "nothing selected"). + LinearSelector sel = new ([0, 1, 2]) { AllowEmpty = true }; + + // Initial state: nothing selected. + Assert.Null (sel.SelectedIndex); + Assert.Equal (0, sel.Value); // default(int) + + // Explicitly select option 0 via SelectedIndex: + sel.SelectedIndex = 0; + Assert.Equal (0, sel.SelectedIndex); // not null — option 0 is selected + Assert.Equal (0, sel.Value); + + // Now clear: + sel.SelectedIndex = null; + Assert.Equal (0, sel.Value); // still default(int) + Assert.Null (sel.SelectedIndex); // unambiguously empty + } + + [Fact] + public void SelectedIndex_Tracks_Value_Setter_Roundtrip () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 30; + Assert.Equal (2, sel.SelectedIndex); + + sel.Value = 10; + Assert.Equal (0, sel.SelectedIndex); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs new file mode 100644 index 0000000000..b567873330 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs @@ -0,0 +1,204 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for . Uses +/// together with +/// InjectKey / InjectMouse through the application pipeline. +/// +public class LinearSelectorVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Initial_Selection_Horizontal () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Glyphs: '●' (option), '─' (space), '█' (set). Index 1 is selected. + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + ●─█─● + 1 2 3 + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Renders_Legends_Without_Highlighting_Set_Option () + { + // Verifies the fix for "Label texts are showing underlined for focused value" — + // legend rows must use a uniform attribute regardless of which option is set. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // All three legend cells should share the same Attribute (no HotNormal / underline for the set option). + Cell [,] contents = app.Driver!.Contents!; + Attribute a0 = contents [1, 0].Attribute!.Value; + Attribute a1 = contents [1, 2].Attribute!.Value; + Attribute a2 = contents [1, 4].Attribute!.Value; + + Assert.Equal (a0, a1); + Assert.Equal (a1, a2); + } + + // Copilot + [Fact] + public void Renders_Vertical () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 6); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Orientation = Orientation.Vertical, Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre ( + "●1\n│ \n█2\n│ \n●3", + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Keyboard_Right_Moves_Focus_Without_Changing_Value_When_AllowEmpty () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 1, AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + sel.SetFocus (); + + Assert.Equal (0, sel.FocusedOption); + Assert.Equal (1, sel.Value); + + app.InjectKey (new Key (KeyCode.CursorRight)); + + Assert.Equal (1, sel.FocusedOption); + + // With AllowEmpty=true, focus moves but value does not change until activation. + Assert.Equal (1, sel.Value); + } + + // Copilot + [Fact] + public void Keyboard_Space_Activates_FocusedOption () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + sel.SetFocus (); + + var changedCount = 0; + sel.ValueChanged += (_, _) => changedCount++; + + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.Space)); + + Assert.Equal (2, sel.Value); + Assert.Equal (1, changedCount); + } + + // Copilot + [Fact] + public void Mouse_Click_Selects_Option_Under_Cursor () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([10, 20, 30]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Resolve the screen position of option index 2 from the view itself. + Assert.True (sel.TryGetPositionByOption (2, out (int x, int y) pos)); + Point screenPos = sel.ViewportToScreen (new Point (pos.x, pos.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = screenPos }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = screenPos }); + + Assert.Equal (30, sel.Value); + } + + // Copilot + [Fact] + public void Mouse_Drag_Updates_Selection_When_AllowEmpty () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + + // AllowEmpty=true was the case where drag-update did NOT work before the fix. + LinearSelector sel = new ([10, 20, 30, 40, 50]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Resolve real screen positions from the view. + Assert.True (sel.TryGetPositionByOption (0, out (int x, int y) p0)); + Assert.True (sel.TryGetPositionByOption (2, out (int x, int y) p2)); + Assert.True (sel.TryGetPositionByOption (4, out (int x, int y) p4)); + Point s0 = sel.ViewportToScreen (new Point (p0.x, p0.y)); + Point s2 = sel.ViewportToScreen (new Point (p2.x, p2.y)); + Point s4 = sel.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at index 0. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s0 }); + + // Drag to index 2 — the bug was that focus did NOT advance here when AllowEmpty=true. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s2 }); + Assert.Equal (2, sel.FocusedOption); + Assert.Equal (30, sel.Value); + + // Drag further to index 4. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s4 }); + Assert.Equal (4, sel.FocusedOption); + Assert.Equal (50, sel.Value); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s4 }); + + // After release the value must remain at the dragged target. + Assert.Equal (50, sel.Value); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs deleted file mode 100644 index c610bb256e..0000000000 --- a/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs +++ /dev/null @@ -1,845 +0,0 @@ -using System.Text; -using UnitTests; - -namespace ViewsTests; - -public class LinearRangeOptionTests : TestDriverBase -{ - [Fact] - public void LinearRange_Option_Default_Constructor () - { - LinearRangeOption o = new (); - Assert.Null (o.Legend); - Assert.Equal (default (Rune), o.LegendAbbr); - Assert.Equal (0, o.Data); - } - - [Fact] - public void LinearRange_Option_Values_Constructor () - { - LinearRangeOption o = new ("1 thousand", new Rune ('y'), 1000); - Assert.Equal ("1 thousand", o.Legend); - Assert.Equal (new Rune ('y'), o.LegendAbbr); - Assert.Equal (1000, o.Data); - } - - [Fact] - public void LinearRangeOption_ToString_WhenEmpty () - { - LinearRangeOption sliderOption = new (); - Assert.Equal ("{Legend=, LegendAbbr=\0, Data=}", sliderOption.ToString ()); - } - - [Fact] - public void LinearRangeOption_ToString_WhenPopulated_WithInt () - { - LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = 1 }; - - Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data=1}", sliderOption.ToString ()); - } - - [Fact] - public void LinearRangeOption_ToString_WhenPopulated_WithSizeF () - { - LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = new SizeF (32, 11) }; - - Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data={Width=32, Height=11}}", sliderOption.ToString ()); - } - - [Fact] - public void OnChanged_Should_Raise_ChangedEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.Changed += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnChanged (true); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnSet_Should_Raise_SetEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.Set += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnSet (); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnUnSet_Should_Raise_UnSetEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.UnSet += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnUnSet (); - - // Assert - Assert.True (eventRaised); - } -} - -public class LinearRangeEventArgsTests : TestDriverBase -{ - [Fact] - public void Constructor_Sets_Cancel_Default_To_False () - { - // Arrange - Dictionary> options = new (); - var focused = 42; - - // Act - LinearRangeEventArgs sliderEventArgs = new (options, focused); - - // Assert - Assert.False (sliderEventArgs.Cancel); - } - - [Fact] - public void Constructor_Sets_Focused () - { - // Arrange - Dictionary> options = new (); - var focused = 42; - - // Act - LinearRangeEventArgs sliderEventArgs = new (options, focused); - - // Assert - Assert.Equal (focused, sliderEventArgs.Focused); - } - - [Fact] - public void Constructor_Sets_Options () - { - // Arrange - Dictionary> options = new (); - - // Act - LinearRangeEventArgs sliderEventArgs = new (options); - - // Assert - Assert.Equal (options, sliderEventArgs.Options); - } -} - -public class LinearRangeTests : TestDriverBase -{ - [Fact] - public void Constructor_Default () - { - // Arrange & Act - LinearRange slider = new (); - - // 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.ShowEndSpacing); - Assert.Equal (LinearRangeType.Single, slider.Type); - Assert.Equal (1, slider.MinimumInnerSpacing); - Assert.True (slider.Width is DimAuto); - Assert.True (slider.Height is DimAuto); - Assert.Equal (0, slider.FocusedOption); - } - - [Fact] - public void Constructor_With_Options () - { - // Arrange - List options = new () { 1, 2, 3 }; - - // Act - LinearRange slider = new (options); - slider.SetRelativeLayout (new Size (100, 100)); - - // Assert - // 0123456789 - // 1 2 3 - Assert.Equal (1, slider.MinimumInnerSpacing); - Assert.Equal (new Size (5, 2), slider.GetContentSize ()); - Assert.Equal (new Size (5, 2), slider.Frame.Size); - Assert.NotNull (slider); - Assert.NotNull (slider.Options); - Assert.Equal (options.Count, slider.Options.Count); - } - - [Fact] - public void MovePlus_Should_MoveFocusRight_When_OptionIsAvailable () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Act - bool result = slider.MovePlus (); - - // Assert - Assert.True (result); - Assert.Equal (1, slider.FocusedOption); - } - - [Fact] - public void MovePlus_Should_NotMoveFocusRight_When_AtEnd () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - slider.FocusedOption = 3; - - // Act - bool result = slider.MovePlus (); - - // Assert - Assert.False (result); - Assert.Equal (3, slider.FocusedOption); - } - - [Fact] - public void OnOptionFocused_Event_Cancelled () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var eventRaised = false; - var cancel = false; - slider.OptionFocused += (sender, args) => eventRaised = true; - var newFocusedOption = 1; - - // Create args with cancel set to false - cancel = false; - - LinearRangeEventArgs args = new (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 LinearRangeEventArgs (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 - } - - [Fact] - public void OnOptionFocused_Event_Raised () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var eventRaised = false; - slider.OptionFocused += (sender, args) => eventRaised = true; - var newFocusedOption = 1; - LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption); - - // Act - slider.OnOptionFocused (newFocusedOption, args); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnOptionsChanged_Event_Raised () - { - // Arrange - LinearRange slider = new (); - var eventRaised = false; - slider.OptionsChanged += (sender, args) => eventRaised = true; - - // Act - slider.OnOptionsChanged (); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void Set_Should_Not_UnSetFocusedOption_When_EmptyNotAllowed () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }) { AllowEmpty = false }; - - Assert.NotEmpty (slider.GetSetOptions ()); - - // Act - bool result = slider.UnSetOption (slider.FocusedOption); - - // Assert - Assert.False (result); - Assert.NotEmpty (slider.GetSetOptions ()); - } - - // Add similar tests for other methods like MoveMinus, MoveStart, MoveEnd, Set, etc. - - [Fact] - public void Set_Should_SetFocusedOption () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Act - slider.FocusedOption = 2; - bool result = slider.Select (); - - // Assert - Assert.True (result); - Assert.Equal (2, slider.FocusedOption); - Assert.Single (slider.GetSetOptions ()); - } - - [Fact] - public void TryGetOptionByPosition_InvalidPosition_Failure () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var x = 10; - var y = 10; - var threshold = 2; - int expectedOption = -1; - - // Act - bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); - - // Assert - Assert.False (result); - Assert.Equal (expectedOption, option); - } - - [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 - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // 0123456789 - // 1234 - - slider.MinimumInnerSpacing = 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 - LinearRange slider = new (new List { 1, 2, 3, 4 }); - slider.Orientation = Orientation.Vertical; - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // 0 1 - // 1 | - // 2 | - // 3 2 - // 4 | - // 5 | - // 6 3 - // 7 | - // 8 | - // 9 4 - - // 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 TryGetPositionByOption_InvalidOption_Failure () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - int option = -1; - (int, int) expectedPosition = (-1, -1); - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) position); - - // Assert - Assert.False (result); - Assert.Equal (expectedPosition, position); - } - - [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 - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // 0123456789 - // 1--2--3--4 - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) 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 - LinearRange slider = new (new List { 1, 2, 3, 4 }); - slider.Orientation = Orientation.Vertical; - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) position); - - // Assert - Assert.True (result); - Assert.Equal (expectedX, position.x); - Assert.Equal (expectedY, position.y); - } - - [Fact] - private void DimAuto_Both_Respects_SuperView_ContentSize () - { - View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; - - List options = ["01234", "01234"]; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (6, 3), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - [Fact] - private void DimAuto_Height_Respects_SuperView_ContentSize () - { - View view = new () { Width = 10, Height = Dim.Fill () }; - - List options = new () { "01234", "01234" }; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple, Width = 10 }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (10, 3), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - [Fact] - private void DimAuto_Width_Respects_SuperView_ContentSize () - { - View view = new () { Width = Dim.Fill (), Height = 10 }; - - List options = new () { "01234", "01234" }; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple, Height = 10 }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (6, 10), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - // https://github.com/gui-cs/Terminal.Gui/issues/3099 - [Fact] - private void One_Option_Does_Not_Throw () - { - // Arrange - LinearRange slider = new (); - slider.BeginInit (); - slider.EndInit (); - - // Act/Assert - slider.Options = [new LinearRangeOption ()]; - } - - // Add more tests for different scenarios and edge cases. -} - -public class LinearRangeCWPTests : TestDriverBase -{ - [Fact] - public void LegendsOrientation_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = Orientation.Horizontal; - var newValue = Orientation.Vertical; - - linearRange.LegendsOrientationChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.LegendsOrientationChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.LegendsOrientation = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.LegendsOrientation); - } - - [Fact] - public void MinimumInnerSpacing_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = 1; - var newValue = 5; - - linearRange.MinimumInnerSpacingChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.MinimumInnerSpacingChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.MinimumInnerSpacing = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.MinimumInnerSpacing); - } - - [Fact] - public void ShowEndSpacing_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = false; - var newValue = true; - - linearRange.ShowEndSpacingChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.ShowEndSpacingChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.ShowEndSpacing = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.ShowEndSpacing); - } - - [Fact] - public void ShowLegends_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = true; - var newValue = false; - - linearRange.ShowLegendsChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.ShowLegendsChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.ShowLegends = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.ShowLegends); - } - - [Fact] - public void Type_PropertyChange_CanBeCancelled () - { - // Arrange - LinearRange linearRange = new (); - LinearRangeType oldValue = linearRange.Type; - - linearRange.TypeChanging += (sender, args) => { args.Handled = true; }; - - // Act - linearRange.Type = LinearRangeType.Range; - - // Assert - Assert.Equal (oldValue, linearRange.Type); - } - - [Fact] - public void Type_PropertyChange_ChangingEventCanModifyNewValue () - { - // Arrange - LinearRange linearRange = new (); - var modifiedValue = LinearRangeType.Multiple; - - linearRange.TypeChanging += (sender, args) => { args.NewValue = modifiedValue; }; - - // Act - linearRange.Type = LinearRangeType.Range; - - // Assert - Assert.Equal (modifiedValue, linearRange.Type); - } - - [Fact] - public void Type_PropertyChange_NoEventsWhenValueUnchanged () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - - linearRange.TypeChanging += (sender, args) => changingRaised = true; - linearRange.TypeChanged += (sender, args) => changedRaised = true; - - // Act - linearRange.Type = linearRange.Type; - - // Assert - Assert.False (changingRaised); - Assert.False (changedRaised); - } - - [Fact] - public void Type_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = LinearRangeType.Single; - var newValue = LinearRangeType.Range; - - linearRange.TypeChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.TypeChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.Type = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.Type); - } - - [Fact] - public void UseMinimumSize_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = false; - var newValue = true; - - linearRange.UseMinimumSizeChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.UseMinimumSizeChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.UseMinimumSize = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.UseMinimumSize); - } - - // Copilot - [Fact] - public void Command_Activate_Calls_SetFocusedOption () - { - LinearRange linearRange = new (); - - linearRange.Options = - [ - new LinearRangeOption ("A", new Rune ('a'), 1), - new LinearRangeOption ("B", new Rune ('b'), 2), - new LinearRangeOption ("C", new Rune ('c'), 3) - ]; - - linearRange.FocusedOption = 1; - - bool? result = linearRange.InvokeCommand (Command.Activate); - - // DefaultActivateHandler returns false for views without dispatch targets or bubble config, - // but the side effect (SetFocusedOption via OnActivated) still occurs. - Assert.False (result); - Assert.Contains (1, linearRange.GetSetOptions ()); - - linearRange.Dispose (); - } - - // Copilot - [Fact] - public void Command_Accept_Calls_SetFocusedOption () - { - LinearRange linearRange = new (); - - linearRange.Options = - [ - new LinearRangeOption ("A", new Rune ('a'), 1), - new LinearRangeOption ("B", new Rune ('b'), 2), - new LinearRangeOption ("C", new Rune ('c'), 3) - ]; - - linearRange.FocusedOption = 2; - - bool? result = linearRange.InvokeCommand (Command.Accept); - - Assert.Contains (2, linearRange.GetSetOptions ()); - - linearRange.Dispose (); - } -} diff --git a/plans/linear-range-ivalue-and-clet.md b/plans/linear-range-ivalue-and-clet.md new file mode 100644 index 0000000000..1acc4e0476 --- /dev/null +++ b/plans/linear-range-ivalue-and-clet.md @@ -0,0 +1,263 @@ +# LinearRange `IValue` Refactor and `linear-range` Clet + +Tracking: [Terminal.Gui#5202](https://github.com/gui-cs/Terminal.Gui/issues/5202) +Downstream consumer: [gui-cs/clet](https://github.com/gui-cs/clet) + +## 1. Problem + +`LinearRange` does not implement `IValue`. The Terminal.Gui v2 convention +(`SelectorBase`, `CheckBox`, `DatePicker`, `ScrollBar`, `Tabs`, `ListView`, +`NumericUpDown`, …) is for any view whose primary purpose is editing a value +to expose that value through `IValue` (`Value` getter/setter, +`ValueChanging` / `ValueChanged` / `ValueChangedUntyped` events). + +This matters now because the [clet](https://github.com/gui-cs/clet) project +wraps Terminal.Gui views as command-line tools. Today, clet ships a +hand-rolled `RangeView` + `RangeClet` that is **not** built on `LinearRange`; +that is duplicated work and leaves the real, feature-rich `LinearRange` (with +typed options, legends, abbreviation, range modes, drag, CWP events) inaccessible +from the CLI. Wiring `LinearRange` into clet requires a single canonical +typed `Value` surface — i.e. `IValue`. + +### The design tension + +`LinearRange`'s "value" is heterogeneous and depends on +`LinearRangeType`: + +| Type | What the user picked | Natural shape | +|----------------|-------------------------------------------------------|---------------------| +| `Single` | 0 or 1 option | `T?` | +| `Multiple` | 0..N options | `IReadOnlyList` | +| `LeftRange` | 1 cut point: "everything ≤ X" | `T` plus a kind tag | +| `RightRange` | 1 cut point: "everything ≥ X" | `T` plus a kind tag | +| `Range` | 2 cut points: closed interval | `(T start, T end)` | + +Today this is exposed as `_setOptions: List` (indices) plus an +`OptionsChanged` event carrying `Dictionary>`. +There is no `Value` and no clean way to bind one. Forcing a single +`IValue` over all five types either over-boxes (`IValue` — +useless for type-safety) or under-fits (`IValue` — drops Multiple/Range). + +## 2. Recommendation: split the family, then implement `IValue` + +Mirror what was done with `SelectorBase` → `OptionSelector` / +`FlagSelector`. Make value semantics part of the **type** +the consumer picks, so each subclass has a single, honest +`IValue`. + +### 2.1 New type family (in `Terminal.Gui/Views/LinearRange/`) + +``` +LinearRangeViewBase abstract base : View, IOrientation, IValue + │ + ├── LinearSelector : LinearRangeViewBase // was Type.Single + │ + ├── LinearMultiSelector : LinearRangeViewBase> // was Type.Multiple + │ + └── LinearRange : LinearRangeViewBase> // was Range / LeftRange / RightRange +``` + +* `LinearRangeViewBase` owns: + the option list (`IReadOnlyList> Options`), + `Orientation`, `LegendsOrientation`, `ShowLegends`, `ShowEndSpacing`, + `MinimumInnerSpacing`, `UseMinimumSize`, `Style`, `AllowEmpty`, + drawing, hit-testing, CWP events for those properties, key/mouse handling, + and the protected `SetSelectedIndices(IReadOnlyList)` plumbing + shared by subclasses. + +* Each subclass exposes its own `Value` via `CWPPropertyHelper.ChangeProperty` + and translates `Value ↔ indices` internally. + +* `LinearRangeType` enum is **deleted** as public surface. + (Internally `LinearRangeViewBase` keeps a `RenderMode` analogue used + only by drawing/hit-testing — `Single`, `Multiple`, `LeftSpan`, `RightSpan`, + `Span` — set by each subclass in its constructor.) + +* The non-generic `LinearRange : LinearRange` shortcut goes away. + Callers that wanted "any options" now pick `LinearSelector`, + `LinearMultiSelector`, or `LinearRange`. + +### 2.2 `LinearRangeSpan` + +```csharp +public readonly record struct LinearRangeSpan +{ + public LinearRangeSpan (LinearRangeSpanKind kind, T? start, T? end, int startIndex, int endIndex) + { … } + + public LinearRangeSpanKind Kind { get; } // None | LeftBounded | RightBounded | Closed + public T? Start { get; } + public T? End { get; } + public int StartIndex { get; } // -1 when not set + public int EndIndex { get; } // -1 when not set + + public static LinearRangeSpan Empty { get; } = new (LinearRangeSpanKind.None, default, default, -1, -1); +} + +public enum LinearRangeSpanKind { None, LeftBounded, RightBounded, Closed } +``` + +This is one struct that can describe all three "range" sub-modes — kind +selects which fields are meaningful. Equality / `record struct` +gives free `EqualityComparer<>` for the CWP guard. + +`LinearRange` exposes `RangeKind { get; set; }` — `LeftBounded`, +`RightBounded`, `Closed` — defaulting to `Closed`. Setting it migrates +the current `Value` (e.g. dropping `End` when switching `Closed → +LeftBounded`). + +### 2.3 Why this works for `IValue` + +| New view | `IValue` satisfies #5202 because… | +|--------------------------|------------------------------------------------------------------------------| +| `LinearSelector` | Drop-in for any "pick one of N typed things" — same shape as `Tabs`, `OptionSelector`. | +| `LinearMultiSelector` | First-class multi-pick view; `Value` is an immutable list, easy to data-bind. | +| `LinearRange` | The honest "range" case; `Value` is a struct that already carries kind. | + +Each is a single concrete `IValue` — no `T?` ambiguity, no +heterogeneous boxing. + +## 3. Migration / breaking changes + +`LinearRange` is alpha and #5202 explicitly trades breakage for a clean +shape. Concretely: + +| Old | New | +|----------------------------------------------|-------------------------------------------------------| +| `LinearRange` (Type=Single) | `LinearSelector` | +| `LinearRange` (Type=Multiple) | `LinearMultiSelector` | +| `LinearRange` (Type=Range / Left / Right) | `LinearRange` with `RangeKind` | +| `LinearRange : LinearRange` | removed; pick the typed subclass | +| `Type` property | removed | +| `SetOption`, `UnSetOption`, `GetSetOptions` | removed; use `Value` setter | +| `OptionsChanged` | replaced by `ValueChanging` / `ValueChanged` | +| `OptionFocused` | retained on the base (focus is independent of value) | + +UICatalog `LinearRanges.cs` and the existing tests +(`LinearRangeTests`, `LinearRangeFluentTests`, +`LinearRangeDefaultKeyBindingsTests`) get migrated as part of the same +PR; net coverage must not drop. + +## 4. Downstream: the `linear-range` clet + +clet currently has `RangeClet` + a custom `RangeView` for numeric +`low..high` input. That stays — it's the *numeric* range tool. The new +clet wraps the new Terminal.Gui views. + +### 4.1 One clet, three modes + +`clet linear-range` covers all three subclasses via `--mode`. This keeps +discovery simple (`clet list` shows one entry) and lets agents pick the +shape they want from a single command. + +``` +clet linear-range \ + --title \ + --mode single | multi | range \ + --options \ + [--initial ] \ + [--orientation horizontal|vertical] \ + [--show-legends] [--no-end-spacing] [--allow-empty] \ + [--range-kind closed|left|right] # only with --mode range + [--json] [--timeout 30s] +``` + +#### Options spec + +`--options` accepts two forms, picked by parsing: + +1. **Labelled enumeration** — `"Free,Pro,Team,Enterprise"`. + Each label becomes a `LinearRangeOption` whose `Data == Legend`. +2. **Numeric range** — `"0..1000:50"` (start..end[:step]). Expands to + `LinearRangeOption` (or `` if all components parse as + integers) with `Legend = value.ToString()`. + +`--initial` matches the same forms: + +| Mode | Initial syntax | +|---------|-----------------------------------------| +| single | `"Pro"` or `"500"` | +| multi | `"Pro,Team"` or `"100,300,500"` | +| range | `"100..500"`, `"..500"` (left), `"100.."` (right) | + +### 4.2 JSON output + +Schema version stays 1, status / cancelled / error envelopes stay as in +the existing clet contract. + +```jsonc +// --mode single +{ "schemaVersion":1, "status":"ok", "mode":"single", + "value":"Pro", "index":1 } + +// --mode multi +{ "schemaVersion":1, "status":"ok", "mode":"multi", + "values":["Pro","Team"], "indices":[1,2] } + +// --mode range (range-kind closed) +{ "schemaVersion":1, "status":"ok", "mode":"range", "kind":"closed", + "start":100, "end":500, "startIndex":2, "endIndex":10 } + +// --mode range (range-kind left) +{ "schemaVersion":1, "status":"ok", "mode":"range", "kind":"left", + "end":500, "endIndex":10 } +``` + +Cancellation / errors use the existing envelopes; exit codes unchanged +(0/1/2/130). + +### 4.3 Mapping from Terminal.Gui to clet + +``` +RangeCletV2 (file: Clets/Input/LinearRangeClet.cs) + --mode single → LinearSelector → result.Value + --mode multi → LinearMultiSelector→ result.Values + --mode range → LinearRange → result.Value : LinearRangeSpan +``` + +All three are `IValue`, so the clet wraps each in +`RunnableWrapper`, awaits Accept/Cancel, and serialises `view.Value` with +a per-mode JSON shape. No special-cases beyond the mode switch. + +## 5. Implementation order + +1. **Add `LinearRangeSpan` + `LinearRangeSpanKind`** under `LinearRange/`. +2. **Extract `LinearRangeViewBase`** from today's + `LinearRange.cs` — keep drawing, layout, options, key/mouse, + focus, CWP for non-value properties; abstract out `SetSelectedIndices` + and value translation. +3. **Add `LinearSelector`** with `IValue`. Migrate single-select + tests. +4. **Add `LinearMultiSelector`** with `IValue>`. + Use a defensive immutable copy in the setter; equality via + `SequenceEqual` in the CWP guard. +5. **Replace `LinearRange`** body with the range-only subclass using + `IValue>`; expose `RangeKind`. +6. **Delete** `LinearRangeType`, the non-generic `LinearRange`, and the + index-centric public methods (`SetOption`, `UnSetOption`, + `GetSetOptions`, `OptionsChanged`, `ChangeOption`). +7. **Migrate** `Examples/UICatalog/Scenarios/LinearRanges.cs` — three + demo views, one per subclass. +8. **Update tests** — port to value-based assertions; new tests for + `Value`/`ValueChanging`/`ValueChanged` on each subclass. +9. **(clet repo)** Add `LinearRangeClet : IClet` next to + `RangeClet.cs`. Don't touch `RangeClet`. + +## 6. Open questions for review + +* **Naming of `LinearSelector` / `LinearMultiSelector`.** + Alternatives: `LinearPicker`, `LinearChoice`, + `RangeSelector` (Multi). `LinearSelector` reads cleanly next to + `OptionSelector` / `FlagSelector` and stays in the slider family. +* **Should the multi value be `ImmutableArray` instead of + `IReadOnlyList`?** Better defensiveness, slightly heavier API. + Lean toward `IReadOnlyList` to match `IValue` precedent + (lightweight contract). +* **`LinearRangeOption` keeps its `Set`/`UnSet`/`Changed` events?** + These are useful per-option signals (e.g. "this tier was just + selected"). Keep them on the base; they fire alongside the new + `ValueChanged` event. +* **Single clet vs three clets in the CLI.** Single `linear-range` + with `--mode` keeps `clet list` lean and matches the structural + symmetry; the alternative would be `linear-select`, `linear-multi`, + `linear-range`. Recommend single.