using System.Collections.Immutable; namespace Terminal.Gui.Views; /// /// The abstract base class for and . /// public abstract class SelectorBase : View, IOrientation { /// /// Initializes a new instance of the class. /// protected SelectorBase () { CanFocus = true; Width = Dim.Auto (DimAutoStyle.Content); Height = Dim.Auto (DimAutoStyle.Content); // ReSharper disable once UseObjectOrCollectionInitializer _orientationHelper = new (this); _orientationHelper.Orientation = Orientation.Vertical; AddCommand (Command.Accept, HandleAcceptCommand); //AddCommand (Command.HotKey, HandleHotKeyCommand); //CreateSubViews (); } /// protected override bool OnClearingViewport () { //SetAttributeForRole (HasFocus ? VisualRole.Focus : VisualRole.Normal); return base.OnClearingViewport (); } private SelectorStyles _styles; /// /// Gets or sets the styles for the flag selector. /// public SelectorStyles Styles { get => _styles; set { if (_styles == value) { return; } _styles = value; CreateSubViews (); UpdateChecked (); } } private bool? HandleAcceptCommand (ICommandContext? ctx) { if (!DoubleClickAccepts && ctx is CommandContext mouseCommandContext && mouseCommandContext.Binding.MouseEventArgs!.Flags.HasFlag (MouseFlags.Button1DoubleClicked)) { return false; } return RaiseAccepting (ctx); } /// protected override bool OnHandlingHotKey (CommandEventArgs args) { // If the command did not come from a keyboard event, ignore it if (args.Context is not CommandContext keyCommandContext) { return base.OnHandlingHotKey (args); } if ((HasFocus || !CanFocus) && HotKey == keyCommandContext.Binding.Key?.NoAlt.NoCtrl.NoShift!) { // It's this.HotKey OR Another View (Label?) forwarded the hotkey command to us - Act just like `Space` (Select) return Focused?.InvokeCommand (Command.Select, args.Context) is true; } return base.OnHandlingHotKey (args); } /// protected override bool OnSelecting (CommandEventArgs args) { return base.OnSelecting (args); } private int? _value; /// /// Gets or sets the value of the selector. Will be if no value is set. /// public virtual int? Value { get => _value; set { if (value is { } && Values is { } && !Values.Contains (value ?? -1)) { throw new ArgumentOutOfRangeException (nameof (value), @$"Value must be one of the following: {string.Join (", ", Values)}"); } if (_value == value) { return; } int? previousValue = _value; _value = value; UpdateChecked (); RaiseValueChanged (previousValue); } } /// /// Raised the event. /// /// protected void RaiseValueChanged (int? previousValue) { if (_valueField is { }) { _valueField.Text = Value.ToString (); } OnValueChanged (Value, previousValue); if (Value.HasValue) { ValueChanged?.Invoke (this, new (Value.Value)); } } /// /// Called when has changed. /// protected virtual void OnValueChanged (int? value, int? previousValue) { } /// /// Raised when has changed. /// public event EventHandler>? ValueChanged; private IReadOnlyList? _values; /// /// Gets or sets the option values. If is , get will /// return values based on the property. /// public virtual IReadOnlyList? Values { get { if (_values is { }) { return _values; } // Use Labels and assume 0..Labels.Count - 1 return Labels is { } ? Enumerable.Range (0, Labels.Count).ToList () : null; } set { _values = value; // Ensure Value defaults to the first valid entry in Values if not already set if (Value is null && _values?.Any () == true) { Value = _values.First (); } CreateSubViews (); UpdateChecked (); } } private IReadOnlyList? _labels; /// /// Gets or sets the list of labels for each value in . /// public IReadOnlyList? Labels { get => _labels; set { _labels = value; CreateSubViews (); UpdateChecked (); } } /// /// Set and from an enum type. /// /// The enum type to extract from /// /// This is a convenience method that converts an enum to a dictionary of values and labels. /// The enum values are converted to int values and the enum names become the labels. /// public void SetValuesAndLabels () where TEnum : struct, Enum { IEnumerable values = Enum.GetValues ().Select (f => Convert.ToInt32 (f)); Values = values.ToImmutableList ().AsReadOnly (); Labels = Enum.GetNames (); } private bool _assignHotKeys; /// /// If each label will automatically be assigned a unique hotkey. /// will be used to ensure unique keys are assigned. Set /// before setting with any hotkeys that may conflict with other Views. /// public bool AssignHotKeys { get => _assignHotKeys; set { if (_assignHotKeys == value) { return; } _assignHotKeys = value; CreateSubViews (); UpdateChecked (); } } /// /// Gets or sets the set of hotkeys that are already used by labels or should not be used when /// is enabled. /// /// This property is used to ensure that automatically assigned hotkeys do not conflict with /// hotkeys used elsewhere in the application. Set before setting /// if there are hotkeys that may conflict with other views. /// /// public HashSet UsedHotKeys { get; set; } = []; private TextField? _valueField; /// /// Creates the subviews for this selector. /// public void CreateSubViews () { foreach (View sv in RemoveAll ()) { if (AssignHotKeys) { UsedHotKeys.Remove (sv.HotKey); } sv.Dispose (); } if (Labels is null) { return; } if (Labels?.Count != Values?.Count) { return; } OnCreatingSubViews (); for (var index = 0; index < Labels?.Count; index++) { Add (CreateCheckBox (Labels.ElementAt (index), Values!.ElementAt (index))); } if (Styles.HasFlag (SelectorStyles.ShowValue)) { _valueField = new () { Id = "valueField", Text = Value.ToString (), // TODO: Don't hardcode this; base it on max Value Width = 5, ReadOnly = true }; Add (_valueField); } OnCreatedSubViews (); AssignUniqueHotKeys (); SetLayout (); } /// /// Called before creates the default subviews (Checkboxes and ValueField). /// protected virtual void OnCreatingSubViews () { } /// /// Called after creates the default subviews (Checkboxes and ValueField). /// protected virtual void OnCreatedSubViews () { } /// /// INTERNAL: Creates a checkbox subview /// protected CheckBox CreateCheckBox (string label, int value) { var checkbox = new CheckBox { CanFocus = true, Title = label, Id = label, Data = value, HighlightStates = MouseState.In, }; return checkbox; } /// /// Assigns unique hotkeys to the labels of the subviews created by . /// private void AssignUniqueHotKeys () { if (!AssignHotKeys || Labels is null) { return; } foreach (View subView in SubViews) { string label = subView.Title ?? string.Empty; // Check if there's already a hotkey defined if (TextFormatter.FindHotKey (label, HotKeySpecifier, out int hotKeyPos, out Key existingHotKey)) { // Label already has a hotkey - preserve it if available if (!UsedHotKeys.Contains (existingHotKey)) { subView.HotKey = existingHotKey; UsedHotKeys.Add (existingHotKey); continue; // Keep existing hotkey specifier in label } else { // Existing hotkey is already used, remove it and assign new one label = TextFormatter.RemoveHotKeySpecifier (label, hotKeyPos, HotKeySpecifier); } } // Assign a new hotkey Rune [] runes = label.EnumerateRunes ().ToArray (); for (var i = 0; i < runes.Count (); i++) { Rune lower = Rune.ToLowerInvariant (runes [i]); var newKey = new Key (lower.Value); if (UsedHotKeys.Contains (newKey)) { continue; } if (!newKey.IsValid || newKey == Key.Empty || newKey == Key.Space || Rune.IsControl (newKey.AsRune)) { continue; } subView.Title = label.Insert (i, HotKeySpecifier.ToString ()); subView.HotKey = newKey; UsedHotKeys.Add (subView.HotKey); break; } } } private int _horizontalSpace = 2; /// /// Gets or sets the horizontal space for this if the is /// /// public int HorizontalSpace { get => _horizontalSpace; set { if (_horizontalSpace != value) { _horizontalSpace = value; SetLayout (); // Pos.Align requires extra layout; good practice to call // Layout to ensure Pos.Align gets updated // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will // TODO: negate need for this hack Layout (); } } } private void SetLayout () { int maxNaturalCheckBoxWidth = 0; if (Values?.Count > 0 && Orientation == Orientation.Vertical) { // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will // TODO: negate need for this hack maxNaturalCheckBoxWidth = SubViews.OfType ().Max ( v => { v.SetRelativeLayout (App?.Screen.Size ?? new Size (2048, 2048)); v.Layout (); return v.Frame.Width; }); } for (var i = 0; i < SubViews.Count; i++) { if (Orientation == Orientation.Vertical) { SubViews.ElementAt (i).X = 0; SubViews.ElementAt (i).Y = Pos.Align (Alignment.Start, AlignmentModes.StartToEnd); SubViews.ElementAt (i).Margin!.Thickness = new (0); SubViews.ElementAt (i).Width = Dim.Func (_ => Math.Max (Viewport.Width, maxNaturalCheckBoxWidth)); } else { SubViews.ElementAt (i).X = Pos.Align (Alignment.Start, AlignmentModes.StartToEnd); SubViews.ElementAt (i).Y = 0; SubViews.ElementAt (i).Margin!.Thickness = new (0, 0, (i < SubViews.Count - 1) ? _horizontalSpace : 0, 0); SubViews.ElementAt (i).Width = Dim.Auto (); } } } /// /// Called when the checked state of the checkboxes needs to be updated. /// /// public abstract void UpdateChecked (); /// /// Gets or sets whether double-clicking on an Item will cause the event to be /// raised. /// /// /// /// If and Accept is not handled, the Accept event on the will /// be raised. The default is /// . /// /// public bool DoubleClickAccepts { get; set; } = true; #region IOrientation /// /// Gets or sets the for this . The default is /// . /// public Orientation Orientation { get => _orientationHelper.Orientation; set => _orientationHelper.Orientation = value; } private readonly OrientationHelper _orientationHelper; #pragma warning disable CS0067 // The event is never used /// public event EventHandler>? OrientationChanging; /// public event EventHandler>? OrientationChanged; #pragma warning restore CS0067 // The event is never used /// Called when has changed. /// public void OnOrientationChanged (Orientation newOrientation) { SetLayout (); // Pos.Align requires extra layout; good practice to call // Layout to ensure Pos.Align gets updated // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will // TODO: negate need for this hack Layout (); } #endregion IOrientation }