123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607 |
- #nullable enable
- using System.Diagnostics;
- namespace Terminal.Gui;
- /// <summary>Displays a group of labels with an idicator of which one is selected.</summary>
- public class RadioGroup : View, IDesignable, IOrientation
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="RadioGroup"/> class.
- /// </summary>
- 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);
- }
- /// <summary>
- /// Gets or sets whether double clicking on a Radio Item will cause the <see cref="View.Accepting"/> event to be raised.
- /// </summary>
- /// <remarks>
- /// <para>
- /// If <see langword="false"/> and Accept is not handled, the Accept event on the <see cref="View.SuperView"/> will
- /// be raised. The default is
- /// <see langword="true"/>.
- /// </para>
- /// </remarks>
- 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;
- /// <summary>
- /// Gets or sets the horizontal space for this <see cref="RadioGroup"/> if the <see cref="Orientation"/> is
- /// <see cref="Orientation.Horizontal"/>
- /// </summary>
- public int HorizontalSpace
- {
- get => _horizontalSpace;
- set
- {
- if (_horizontalSpace != value && Orientation == Orientation.Horizontal)
- {
- _horizontalSpace = value;
- UpdateTextFormatterText ();
- SetContentSize ();
- }
- }
- }
- private List<string> _radioLabels = [];
- /// <summary>
- /// The radio labels to display. A <see cref="Command.HotKey"/> key binding will be added for each label enabling the
- /// user to select
- /// and/or focus the radio label using the keyboard. See <see cref="View.HotKey"/> for details on how HotKeys work.
- /// </summary>
- /// <value>The radio labels.</value>
- 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;
- /// <summary>Gets or sets the selected radio label index.</summary>
- /// <value>The index. -1 if no item is selected.</value>
- public int SelectedItem
- {
- get => _selected;
- set => ChangeSelectedItem (value);
- }
- /// <summary>
- /// INTERNAL Sets the selected item.
- /// </summary>
- /// <param name="value"></param>
- /// <returns>
- /// <see langword="true"/> if the selected item changed.
- /// </returns>
- 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;
- }
- /// <inheritdoc/>
- 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
- /// <summary>
- /// Gets or sets the <see cref="Orientation"/> for this <see cref="RadioGroup"/>. The default is
- /// <see cref="Orientation.Vertical"/>.
- /// </summary>
- public Orientation Orientation
- {
- get => _orientationHelper.Orientation;
- set => _orientationHelper.Orientation = value;
- }
- private readonly OrientationHelper _orientationHelper;
- /// <inheritdoc/>
- public event EventHandler<CancelEventArgs<Orientation>>? OrientationChanging;
- /// <inheritdoc/>
- public event EventHandler<EventArgs<Orientation>>? OrientationChanged;
- /// <summary>Called when <see cref="Orientation"/> has changed.</summary>
- /// <param name="newOrientation"></param>
- public void OnOrientationChanged (Orientation newOrientation)
- {
- SetupKeyBindings ();
- SetContentSize ();
- }
- #endregion IOrientation
- // TODO: Add a SelectedItemChanging event like CheckBox has.
- /// <summary>Called whenever the current selected item changes. Invokes the <see cref="SelectedItemChanged"/> event.</summary>
- /// <param name="selectedItem"></param>
- /// <param name="previousSelectedItem"></param>
- protected virtual void OnSelectedItemChanged (int selectedItem, int previousSelectedItem) { }
- /// <summary>
- /// Gets or sets the <see cref="RadioLabels"/> index for the cursor. The cursor may or may not be the selected
- /// RadioItem.
- /// </summary>
- /// <remarks>
- /// <para>
- /// Maps to either the X or Y position within <see cref="View.Viewport"/> depending on <see cref="Orientation"/>.
- /// </para>
- /// </remarks>
- public int Cursor { get; set; }
- /// <inheritdoc/>
- 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
- }
- /// <summary>Raised when the selected radio label has changed.</summary>
- public event EventHandler<SelectedItemChangedArgs>? 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;
- }
- }
- /// <inheritdoc/>
- public bool EnableForDesign ()
- {
- RadioLabels = new [] { "Option _1", "Option _2", "Option _3" };
- return true;
- }
- }
|