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
}