#nullable enable using System.Diagnostics; namespace Terminal.Gui; /// Displays a group of labels with an idicator of which one is selected. public class RadioGroup : View, IDesignable, IOrientation { /// /// Initializes a new instance of the class. /// public RadioGroup () { CanFocus = true; Width = Dim.Auto (DimAutoStyle.Content); Height = Dim.Auto (DimAutoStyle.Content); // Select (Space key or mouse click) - The default implementation sets focus. RadioGroup does not. AddCommand ( Command.Select, (ctx) => { bool cursorChanged = false; if (SelectedItem == Cursor) { cursorChanged = MoveDownRight (); if (!cursorChanged) { cursorChanged = MoveHome (); } } bool selectedItemChanged = false; if (SelectedItem != Cursor) { selectedItemChanged = ChangeSelectedItem (Cursor); } if (cursorChanged || selectedItemChanged) { if (RaiseSelecting (ctx) == true) { return true; } } return cursorChanged || selectedItemChanged; }); // Accept (Enter key) - Raise Accept event - DO NOT advance state AddCommand (Command.Accept, RaiseAccepting); // Hotkey - ctx may indicate a radio item hotkey was pressed. Behavior depends on HasFocus // If HasFocus and it's this.HotKey invoke Select command - DO NOT raise Accept // If it's a radio item HotKey select that item and raise Selected event - DO NOT raise Accept // If nothing is selected, select first and raise Selected event - DO NOT raise Accept AddCommand (Command.HotKey, ctx => { var item = ctx.KeyBinding?.Context as int?; if (HasFocus) { if (ctx is { KeyBinding: { } } && (ctx.KeyBinding.Value.BoundView != this || HotKey == ctx.Key?.NoAlt.NoCtrl.NoShift)) { // It's this.HotKey OR Another View (Label?) forwarded the hotkey command to us - Act just like `Space` (Select) return InvokeCommand (Command.Select, ctx.Key, ctx.KeyBinding); } } if (item is { } && item < _radioLabels.Count) { if (item.Value == SelectedItem) { return true; } // If a RadioItem.HotKey is pressed we always set the selected item - never SetFocus bool selectedItemChanged = ChangeSelectedItem (item.Value); if (selectedItemChanged) { // Doesn't matter if it's handled RaiseSelecting (ctx); return true; } return false; } if (SelectedItem == -1 && ChangeSelectedItem (0)) { if (RaiseSelecting (ctx) == true) { return true; } return false; } if (RaiseHandlingHotKey () == true) { return true; }; // Default Command.Hotkey sets focus SetFocus (); return true; }); AddCommand ( Command.Up, () => { if (!HasFocus) { return false; } return MoveUpLeft (); } ); AddCommand ( Command.Down, () => { if (!HasFocus) { return false; } return MoveDownRight (); } ); AddCommand ( Command.Start, () => { if (!HasFocus) { return false; } MoveHome (); return true; } ); AddCommand ( Command.End, () => { if (!HasFocus) { return false; } MoveEnd (); return true; } ); // ReSharper disable once UseObjectOrCollectionInitializer _orientationHelper = new (this); _orientationHelper.Orientation = Orientation.Vertical; _orientationHelper.OrientationChanging += (sender, e) => OrientationChanging?.Invoke (this, e); _orientationHelper.OrientationChanged += (sender, e) => OrientationChanged?.Invoke (this, e); SetupKeyBindings (); SubviewLayout += RadioGroup_LayoutStarted; HighlightStyle = HighlightStyle.PressedOutside | HighlightStyle.Pressed; MouseClick += RadioGroup_MouseClick; } // TODO: Fix InvertColorsOnPress - only highlight the selected item private void SetupKeyBindings () { // Default keybindings for this view if (Orientation == Orientation.Vertical) { KeyBindings.Remove (Key.CursorUp); KeyBindings.Add (Key.CursorUp, Command.Up); KeyBindings.Remove (Key.CursorDown); KeyBindings.Add (Key.CursorDown, Command.Down); } else { KeyBindings.Remove (Key.CursorLeft); KeyBindings.Add (Key.CursorLeft, Command.Up); KeyBindings.Remove (Key.CursorRight); KeyBindings.Add (Key.CursorRight, Command.Down); } KeyBindings.Remove (Key.Home); KeyBindings.Add (Key.Home, Command.Start); KeyBindings.Remove (Key.End); KeyBindings.Add (Key.End, Command.End); } /// /// Gets or sets whether double clicking on a Radio 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; private void RadioGroup_MouseClick (object? sender, MouseEventArgs e) { if (e.Flags.HasFlag (MouseFlags.Button1Clicked)) { int viewportX = e.Position.X; int viewportY = e.Position.Y; int pos = Orientation == Orientation.Horizontal ? viewportX : viewportY; int rCount = Orientation == Orientation.Horizontal ? _horizontal!.Last ().pos + _horizontal!.Last ().length : _radioLabels.Count; if (pos < rCount) { int c = Orientation == Orientation.Horizontal ? _horizontal!.FindIndex (x => x.pos <= viewportX && x.pos + x.length - 2 >= viewportX) : viewportY; if (c > -1) { // Just like the user pressing the items' hotkey e.Handled = InvokeCommand (Command.HotKey, null, new KeyBinding ([Command.HotKey], KeyBindingScope.HotKey, boundView: this, context: c)) == true; } } return; } if (DoubleClickAccepts && e.Flags.HasFlag (MouseFlags.Button1DoubleClicked)) { // NOTE: Drivers ALWAYS generate a Button1Clicked event before Button1DoubleClicked // NOTE: So, we've already selected an item. // Just like the user pressing `Enter` InvokeCommand (Command.Accept); } // HACK: Always eat so Select is not invoked by base e.Handled = true; } private List<(int pos, int length)>? _horizontal; private int _horizontalSpace = 2; /// /// Gets or sets the horizontal space for this if the is /// /// public int HorizontalSpace { get => _horizontalSpace; set { if (_horizontalSpace != value && Orientation == Orientation.Horizontal) { _horizontalSpace = value; UpdateTextFormatterText (); SetContentSize (); } } } private List _radioLabels = []; /// /// The radio labels to display. A key binding will be added for each label enabling the /// user to select /// and/or focus the radio label using the keyboard. See for details on how HotKeys work. /// /// The radio labels. public string [] RadioLabels { get => _radioLabels.ToArray (); set { // Remove old hot key bindings foreach (string label in _radioLabels) { if (TextFormatter.FindHotKey (label, HotKeySpecifier, out _, out Key hotKey)) { AddKeyBindingsForHotKey (hotKey, Key.Empty); } } int prevCount = _radioLabels.Count; _radioLabels = value.ToList (); for (var index = 0; index < _radioLabels.Count; index++) { string label = _radioLabels [index]; if (TextFormatter.FindHotKey (label, HotKeySpecifier, out _, out Key hotKey)) { AddKeyBindingsForHotKey (Key.Empty, hotKey, index); } } SelectedItem = 0; SetContentSize (); } } private int _selected; /// Gets or sets the selected radio label index. /// The index. -1 if no item is selected. public int SelectedItem { get => _selected; set => ChangeSelectedItem (value); } /// /// INTERNAL Sets the selected item. /// /// /// /// if the selected item changed. /// private bool ChangeSelectedItem (int value) { if (_selected == value || value > _radioLabels.Count - 1) { return false; } int savedSelected = _selected; _selected = value; Cursor = Math.Max (_selected, 0); OnSelectedItemChanged (value, SelectedItem); SelectedItemChanged?.Invoke (this, new (SelectedItem, savedSelected)); SetNeedsDraw (); return true; } /// protected override bool OnDrawingContent (Rectangle viewport) { SetAttribute (GetNormalColor ()); for (var i = 0; i < _radioLabels.Count; i++) { switch (Orientation) { case Orientation.Vertical: Move (0, i); break; case Orientation.Horizontal: Move (_horizontal! [i].pos, 0); break; } string rl = _radioLabels [i]; SetAttribute (GetNormalColor ()); Driver?.AddStr ($"{(i == _selected ? Glyphs.Selected : Glyphs.UnSelected)} "); TextFormatter.FindHotKey (rl, HotKeySpecifier, out int hotPos, out Key hotKey); if (hotPos != -1 && hotKey != Key.Empty) { Rune [] rlRunes = rl.ToRunes (); for (var j = 0; j < rlRunes.Length; j++) { Rune rune = rlRunes [j]; if (j == hotPos && i == Cursor) { SetAttribute ( HasFocus ? ColorScheme!.HotFocus : GetHotNormalColor () ); } else if (j == hotPos && i != Cursor) { SetAttribute (GetHotNormalColor ()); } else if (HasFocus && i == Cursor) { SetAttribute (GetFocusColor ()); } if (rune == HotKeySpecifier && j + 1 < rlRunes.Length) { j++; rune = rlRunes [j]; if (i == Cursor) { SetAttribute ( HasFocus ? ColorScheme!.HotFocus : GetHotNormalColor () ); } else if (i != Cursor) { SetAttribute (GetHotNormalColor ()); } } Application.Driver?.AddRune (rune); SetAttribute (GetNormalColor ()); } } else { DrawHotString (rl, HasFocus && i == Cursor); } } return 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; /// public event EventHandler>? OrientationChanging; /// public event EventHandler>? OrientationChanged; /// Called when has changed. /// public void OnOrientationChanged (Orientation newOrientation) { SetupKeyBindings (); SetContentSize (); } #endregion IOrientation // TODO: Add a SelectedItemChanging event like CheckBox has. /// Called whenever the current selected item changes. Invokes the event. /// /// protected virtual void OnSelectedItemChanged (int selectedItem, int previousSelectedItem) { } /// /// Gets or sets the index for the cursor. The cursor may or may not be the selected /// RadioItem. /// /// /// /// Maps to either the X or Y position within depending on . /// /// public int Cursor { get; set; } /// public override Point? PositionCursor () { var x = 0; var y = 0; switch (Orientation) { case Orientation.Vertical: y = Cursor; break; case Orientation.Horizontal: if (_horizontal!.Count > 0) { x = _horizontal [Cursor].pos; } break; default: return null; } Move (x, y); return null; // Don't show the cursor } /// Raised when the selected radio label has changed. public event EventHandler? SelectedItemChanged; private bool MoveDownRight () { if (Cursor + 1 < _radioLabels.Count) { Cursor++; SetNeedsDraw (); return true; } // Moving past should move focus to next view, not wrap return false; } private void MoveEnd () { Cursor = Math.Max (_radioLabels.Count - 1, 0); } private bool MoveHome () { if (Cursor != 0) { Cursor = 0; return true; } return false; } private bool MoveUpLeft () { if (Cursor > 0) { Cursor--; SetNeedsDraw (); return true; } // Moving past should move focus to next view, not wrap return false; } private void RadioGroup_LayoutStarted (object? sender, EventArgs e) { SetContentSize (); } private void SetContentSize () { switch (Orientation) { case Orientation.Vertical: var width = 0; foreach (string s in _radioLabels) { width = Math.Max (s.GetColumns () + 2, width); } SetContentSize (new (width, _radioLabels.Count)); break; case Orientation.Horizontal: _horizontal = new (); var start = 0; var length = 0; for (var i = 0; i < _radioLabels.Count; i++) { start += length; length = _radioLabels [i].GetColumns () + 2 + (i < _radioLabels.Count - 1 ? _horizontalSpace : 0); _horizontal.Add ((start, length)); } SetContentSize (new (_horizontal.Sum (item => item.length), 1)); break; } } /// public bool EnableForDesign () { RadioLabels = new [] { "Option _1", "Option _2", "Option _3" }; return true; } }