RadioGroup.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. #nullable enable
  2. namespace Terminal.Gui.Views;
  3. /// <summary>Displays a list of mutually-exclusive items. Each items can have its own hotkey.</summary>
  4. public class RadioGroup : View, IDesignable, IOrientation
  5. {
  6. /// <summary>
  7. /// Initializes a new instance of the <see cref="RadioGroup"/> class.
  8. /// </summary>
  9. public RadioGroup ()
  10. {
  11. CanFocus = true;
  12. Width = Dim.Auto (DimAutoStyle.Content);
  13. Height = Dim.Auto (DimAutoStyle.Content);
  14. // Select (Space key or mouse click) - The default implementation sets focus. RadioGroup does not.
  15. AddCommand (Command.Select, HandleSelectCommand);
  16. // Accept (Enter key or DoubleClick) - Raise Accept event - DO NOT advance state
  17. AddCommand (Command.Accept, HandleAcceptCommand);
  18. // Hotkey - ctx may indicate a radio item hotkey was pressed. Behavior depends on HasFocus
  19. // If HasFocus and it's this.HotKey invoke Select command - DO NOT raise Accept
  20. // If it's a radio item HotKey select that item and raise Selected event - DO NOT raise Accept
  21. // If nothing is selected, select first and raise Selected event - DO NOT raise Accept
  22. AddCommand (Command.HotKey, HandleHotKeyCommand);
  23. AddCommand (Command.Up, () => HasFocus && MoveUpLeft ());
  24. AddCommand (Command.Down, () => HasFocus && MoveDownRight ());
  25. AddCommand (Command.Start, () => HasFocus && MoveHome ());
  26. AddCommand (Command.End, () => HasFocus && MoveEnd ());
  27. // ReSharper disable once UseObjectOrCollectionInitializer
  28. _orientationHelper = new (this);
  29. _orientationHelper.Orientation = Orientation.Vertical;
  30. SetupKeyBindings ();
  31. // By default, single click is already bound to Command.Select
  32. MouseBindings.Add (MouseFlags.Button1DoubleClicked, Command.Accept);
  33. SubViewLayout += RadioGroup_LayoutStarted;
  34. }
  35. private bool? HandleHotKeyCommand (ICommandContext? ctx)
  36. {
  37. // If the command did not come from a keyboard event, ignore it
  38. if (ctx is not CommandContext<KeyBinding> keyCommandContext)
  39. {
  40. return false;
  41. }
  42. var item = keyCommandContext.Binding.Data as int?;
  43. if (HasFocus)
  44. {
  45. if (item is null || HotKey == keyCommandContext.Binding.Key?.NoAlt.NoCtrl.NoShift!)
  46. {
  47. // It's this.HotKey OR Another View (Label?) forwarded the hotkey command to us - Act just like `Space` (Select)
  48. return InvokeCommand (Command.Select);
  49. }
  50. }
  51. if (item is { } && item < _radioLabels.Count)
  52. {
  53. if (item.Value == SelectedItem)
  54. {
  55. return true;
  56. }
  57. // If a RadioItem.HotKey is pressed we always set the selected item - never SetFocus
  58. bool selectedItemChanged = ChangeSelectedItem (item.Value);
  59. if (selectedItemChanged)
  60. {
  61. // Doesn't matter if it's handled
  62. RaiseSelecting (ctx);
  63. return true;
  64. }
  65. return false;
  66. }
  67. if (SelectedItem == -1 && ChangeSelectedItem (0))
  68. {
  69. if (RaiseSelecting (ctx) == true)
  70. {
  71. return true;
  72. }
  73. return false;
  74. }
  75. if (RaiseHandlingHotKey (ctx) == true)
  76. {
  77. return true;
  78. }
  79. ;
  80. // Default Command.Hotkey sets focus
  81. SetFocus ();
  82. return true;
  83. }
  84. private bool? HandleAcceptCommand (ICommandContext? ctx)
  85. {
  86. if (!DoubleClickAccepts
  87. && ctx is CommandContext<MouseBinding> mouseCommandContext
  88. && mouseCommandContext.Binding.MouseEventArgs!.Flags.HasFlag (MouseFlags.Button1DoubleClicked))
  89. {
  90. return false;
  91. }
  92. return RaiseAccepting (ctx);
  93. }
  94. private bool? HandleSelectCommand (ICommandContext? ctx)
  95. {
  96. if (ctx is CommandContext<MouseBinding> mouseCommandContext
  97. && mouseCommandContext.Binding.MouseEventArgs!.Flags.HasFlag (MouseFlags.Button1Clicked))
  98. {
  99. int viewportX = mouseCommandContext.Binding.MouseEventArgs.Position.X;
  100. int viewportY = mouseCommandContext.Binding.MouseEventArgs.Position.Y;
  101. int pos = Orientation == Orientation.Horizontal ? viewportX : viewportY;
  102. int rCount = Orientation == Orientation.Horizontal
  103. ? _horizontal!.Last ().pos + _horizontal!.Last ().length
  104. : _radioLabels.Count;
  105. if (pos < rCount)
  106. {
  107. int c = Orientation == Orientation.Horizontal
  108. ? _horizontal!.FindIndex (x => x.pos <= viewportX && x.pos + x.length - 2 >= viewportX)
  109. : viewportY;
  110. if (c > -1)
  111. {
  112. // Just like the user pressing the items' hotkey
  113. return InvokeCommand (Command.HotKey, new KeyBinding ([Command.HotKey], this, c)) == true;
  114. }
  115. }
  116. return false;
  117. }
  118. var cursorChanged = false;
  119. if (SelectedItem == Cursor)
  120. {
  121. cursorChanged = MoveDownRight ();
  122. if (!cursorChanged)
  123. {
  124. cursorChanged = MoveHome ();
  125. }
  126. }
  127. var selectedItemChanged = false;
  128. if (SelectedItem != Cursor)
  129. {
  130. selectedItemChanged = ChangeSelectedItem (Cursor);
  131. }
  132. if (cursorChanged || selectedItemChanged)
  133. {
  134. if (RaiseSelecting (ctx) == true)
  135. {
  136. return true;
  137. }
  138. }
  139. return cursorChanged || selectedItemChanged;
  140. }
  141. // TODO: Fix InvertColorsOnPress - only highlight the selected item
  142. private void SetupKeyBindings ()
  143. {
  144. // Default keybindings for this view
  145. if (Orientation == Orientation.Vertical)
  146. {
  147. KeyBindings.Remove (Key.CursorUp);
  148. KeyBindings.Add (Key.CursorUp, Command.Up);
  149. KeyBindings.Remove (Key.CursorDown);
  150. KeyBindings.Add (Key.CursorDown, Command.Down);
  151. }
  152. else
  153. {
  154. KeyBindings.Remove (Key.CursorLeft);
  155. KeyBindings.Add (Key.CursorLeft, Command.Up);
  156. KeyBindings.Remove (Key.CursorRight);
  157. KeyBindings.Add (Key.CursorRight, Command.Down);
  158. }
  159. KeyBindings.Remove (Key.Home);
  160. KeyBindings.Add (Key.Home, Command.Start);
  161. KeyBindings.Remove (Key.End);
  162. KeyBindings.Add (Key.End, Command.End);
  163. }
  164. /// <summary>
  165. /// Gets or sets whether double-clicking on a Radio Item will cause the <see cref="View.Accepting"/> event to be
  166. /// raised.
  167. /// </summary>
  168. /// <remarks>
  169. /// <para>
  170. /// If <see langword="false"/> and Accept is not handled, the Accept event on the <see cref="View.SuperView"/> will
  171. /// be raised. The default is
  172. /// <see langword="true"/>.
  173. /// </para>
  174. /// </remarks>
  175. public bool DoubleClickAccepts { get; set; } = true;
  176. private List<(int pos, int length)>? _horizontal;
  177. private int _horizontalSpace = 2;
  178. /// <summary>
  179. /// Gets or sets the horizontal space for this <see cref="RadioGroup"/> if the <see cref="Orientation"/> is
  180. /// <see cref="Orientation.Horizontal"/>
  181. /// </summary>
  182. public int HorizontalSpace
  183. {
  184. get => _horizontalSpace;
  185. set
  186. {
  187. if (_horizontalSpace != value && Orientation == Orientation.Horizontal)
  188. {
  189. _horizontalSpace = value;
  190. UpdateTextFormatterText ();
  191. SetContentSize ();
  192. }
  193. }
  194. }
  195. /// <summary>
  196. /// If <see langword="true"/> the <see cref="RadioLabels"/> will each be automatically assigned a hotkey.
  197. /// <see cref="UsedHotKeys"/> will be used to ensure unique keys are assigned. Set <see cref="UsedHotKeys"/>
  198. /// before setting <see cref="RadioLabels"/> with any hotkeys that may conflict with other Views.
  199. /// </summary>
  200. public bool AssignHotKeysToRadioLabels { get; set; }
  201. /// <summary>
  202. /// Gets the list of hotkeys already used by <see cref="RadioLabels"/> or that should not be used if
  203. /// <see cref="AssignHotKeysToRadioLabels"/>
  204. /// is enabled.
  205. /// </summary>
  206. public List<Key> UsedHotKeys { get; } = [];
  207. private readonly List<string> _radioLabels = [];
  208. /// <summary>
  209. /// The radio labels to display. A <see cref="Command.HotKey"/> key binding will be added for each label enabling the
  210. /// user to select
  211. /// and/or focus the radio label using the keyboard. See <see cref="View.HotKey"/> for details on how HotKeys work.
  212. /// </summary>
  213. /// <value>The radio labels.</value>
  214. public string [] RadioLabels
  215. {
  216. get => _radioLabels.ToArray ();
  217. set
  218. {
  219. // Remove old hot key bindings
  220. foreach (string label in _radioLabels)
  221. {
  222. if (TextFormatter.FindHotKey (label, HotKeySpecifier, out _, out Key hotKey))
  223. {
  224. AddKeyBindingsForHotKey (hotKey, Key.Empty);
  225. }
  226. }
  227. _radioLabels.Clear ();
  228. // Pick a unique hotkey for each radio label
  229. for (var labelIndex = 0; labelIndex < value.Length; labelIndex++)
  230. {
  231. string name = value [labelIndex];
  232. string? nameWithHotKey = name;
  233. if (AssignHotKeysToRadioLabels)
  234. {
  235. // Find the first char in label that is [a-z], [A-Z], or [0-9]
  236. for (var i = 0; i < name.Length; i++)
  237. {
  238. char c = char.ToLowerInvariant (name [i]);
  239. if (UsedHotKeys.Contains (new (c)) || !char.IsAsciiLetterOrDigit (c))
  240. {
  241. continue;
  242. }
  243. if (char.IsAsciiLetterOrDigit (c))
  244. {
  245. char? hotChar = c;
  246. nameWithHotKey = name.Insert (i, HotKeySpecifier.ToString ());
  247. UsedHotKeys.Add (new (hotChar));
  248. break;
  249. }
  250. }
  251. }
  252. _radioLabels.Add (nameWithHotKey);
  253. if (TextFormatter.FindHotKey (nameWithHotKey, HotKeySpecifier, out _, out Key hotKey))
  254. {
  255. AddKeyBindingsForHotKey (Key.Empty, hotKey, labelIndex);
  256. }
  257. }
  258. SelectedItem = 0;
  259. SetContentSize ();
  260. }
  261. }
  262. private int _selected;
  263. /// <summary>Gets or sets the selected radio label index.</summary>
  264. /// <value>The index. -1 if no item is selected.</value>
  265. public int SelectedItem
  266. {
  267. get => _selected;
  268. set => ChangeSelectedItem (value);
  269. }
  270. /// <summary>
  271. /// INTERNAL Sets the selected item.
  272. /// </summary>
  273. /// <param name="value"></param>
  274. /// <returns>
  275. /// <see langword="true"/> if the selected item changed.
  276. /// </returns>
  277. private bool ChangeSelectedItem (int value)
  278. {
  279. if (_selected == value || value > _radioLabels.Count - 1)
  280. {
  281. return false;
  282. }
  283. int savedSelected = _selected;
  284. _selected = value;
  285. Cursor = Math.Max (_selected, 0);
  286. OnSelectedItemChanged (value, SelectedItem);
  287. SelectedItemChanged?.Invoke (this, new (SelectedItem, savedSelected));
  288. SetNeedsDraw ();
  289. return true;
  290. }
  291. /// <inheritdoc/>
  292. protected override bool OnDrawingContent ()
  293. {
  294. SetAttribute (GetAttributeForRole (VisualRole.Normal));
  295. for (var i = 0; i < _radioLabels.Count; i++)
  296. {
  297. switch (Orientation)
  298. {
  299. case Orientation.Vertical:
  300. Move (0, i);
  301. break;
  302. case Orientation.Horizontal:
  303. Move (_horizontal! [i].pos, 0);
  304. break;
  305. }
  306. string rl = _radioLabels [i];
  307. SetAttribute (GetAttributeForRole (VisualRole.Normal));
  308. AddStr ($"{(i == _selected ? Glyphs.Selected : Glyphs.UnSelected)} ");
  309. TextFormatter.FindHotKey (rl, HotKeySpecifier, out int hotPos, out Key hotKey);
  310. if (hotPos != -1 && hotKey != Key.Empty)
  311. {
  312. Rune [] rlRunes = rl.ToRunes ();
  313. for (var j = 0; j < rlRunes.Length; j++)
  314. {
  315. Rune rune = rlRunes [j];
  316. if (j == hotPos && i == Cursor)
  317. {
  318. SetAttribute (HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal));
  319. }
  320. else if (j == hotPos && i != Cursor)
  321. {
  322. SetAttribute (GetAttributeForRole (VisualRole.HotNormal));
  323. }
  324. else if (HasFocus && i == Cursor)
  325. {
  326. SetAttribute (GetAttributeForRole (VisualRole.Focus));
  327. }
  328. if (rune == HotKeySpecifier && j + 1 < rlRunes.Length)
  329. {
  330. j++;
  331. rune = rlRunes [j];
  332. if (i == Cursor)
  333. {
  334. SetAttribute (HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal));
  335. }
  336. else if (i != Cursor)
  337. {
  338. SetAttribute (GetAttributeForRole (VisualRole.HotNormal));
  339. }
  340. }
  341. Application.Driver?.AddRune (rune);
  342. SetAttribute (GetAttributeForRole (VisualRole.Normal));
  343. }
  344. }
  345. else
  346. {
  347. DrawHotString (rl, HasFocus && i == Cursor);
  348. }
  349. }
  350. return true;
  351. }
  352. #region IOrientation
  353. /// <summary>
  354. /// Gets or sets the <see cref="Orientation"/> for this <see cref="RadioGroup"/>. The default is
  355. /// <see cref="Orientation.Vertical"/>.
  356. /// </summary>
  357. public Orientation Orientation
  358. {
  359. get => _orientationHelper.Orientation;
  360. set => _orientationHelper.Orientation = value;
  361. }
  362. private readonly OrientationHelper _orientationHelper;
  363. #pragma warning disable CS0067 // The event is never used
  364. /// <inheritdoc/>
  365. public event EventHandler<CancelEventArgs<Orientation>>? OrientationChanging;
  366. /// <inheritdoc/>
  367. public event EventHandler<EventArgs<Orientation>>? OrientationChanged;
  368. #pragma warning restore CS0067 // The event is never used
  369. #pragma warning restore CS0067
  370. /// <summary>Called when <see cref="Orientation"/> has changed.</summary>
  371. /// <param name="newOrientation"></param>
  372. public void OnOrientationChanged (Orientation newOrientation)
  373. {
  374. SetupKeyBindings ();
  375. SetContentSize ();
  376. }
  377. #endregion IOrientation
  378. // TODO: Add a SelectedItemChanging event like CheckBox has.
  379. /// <summary>Called whenever the current selected item changes. Invokes the <see cref="SelectedItemChanged"/> event.</summary>
  380. /// <param name="selectedItem"></param>
  381. /// <param name="previousSelectedItem"></param>
  382. protected virtual void OnSelectedItemChanged (int selectedItem, int previousSelectedItem) { }
  383. /// <summary>
  384. /// Gets or sets the <see cref="RadioLabels"/> index for the cursor. The cursor may or may not be the selected
  385. /// RadioItem.
  386. /// </summary>
  387. /// <remarks>
  388. /// <para>
  389. /// Maps to either the X or Y position within <see cref="View.Viewport"/> depending on <see cref="Orientation"/>.
  390. /// </para>
  391. /// </remarks>
  392. public int Cursor { get; set; }
  393. /// <inheritdoc/>
  394. public override Point? PositionCursor ()
  395. {
  396. var x = 0;
  397. var y = 0;
  398. switch (Orientation)
  399. {
  400. case Orientation.Vertical:
  401. y = Cursor;
  402. break;
  403. case Orientation.Horizontal:
  404. if (_horizontal!.Count > 0)
  405. {
  406. x = _horizontal [Cursor].pos;
  407. }
  408. break;
  409. default:
  410. return null;
  411. }
  412. Move (x, y);
  413. return null; // Don't show the cursor
  414. }
  415. /// <summary>Raised when the selected radio label has changed.</summary>
  416. public event EventHandler<SelectedItemChangedArgs>? SelectedItemChanged;
  417. private bool MoveDownRight ()
  418. {
  419. if (Cursor + 1 < _radioLabels.Count)
  420. {
  421. Cursor++;
  422. SetNeedsDraw ();
  423. return true;
  424. }
  425. // Moving past should move focus to next view, not wrap
  426. return false;
  427. }
  428. private bool MoveEnd ()
  429. {
  430. Cursor = Math.Max (_radioLabels.Count - 1, 0);
  431. return true;
  432. }
  433. private bool MoveHome ()
  434. {
  435. if (Cursor != 0)
  436. {
  437. Cursor = 0;
  438. return true;
  439. }
  440. return false;
  441. }
  442. private bool MoveUpLeft ()
  443. {
  444. if (Cursor > 0)
  445. {
  446. Cursor--;
  447. SetNeedsDraw ();
  448. return true;
  449. }
  450. // Moving past should move focus to next view, not wrap
  451. return false;
  452. }
  453. private void RadioGroup_LayoutStarted (object? sender, EventArgs e) { SetContentSize (); }
  454. private void SetContentSize ()
  455. {
  456. switch (Orientation)
  457. {
  458. case Orientation.Vertical:
  459. var width = 0;
  460. foreach (string s in _radioLabels)
  461. {
  462. width = Math.Max (s.GetColumns () + 2, width);
  463. }
  464. SetContentSize (new (width, _radioLabels.Count));
  465. break;
  466. case Orientation.Horizontal:
  467. _horizontal = new ();
  468. var start = 0;
  469. var length = 0;
  470. for (var i = 0; i < _radioLabels.Count; i++)
  471. {
  472. start += length;
  473. length = _radioLabels [i].GetColumns () + 2 + (i < _radioLabels.Count - 1 ? _horizontalSpace : 0);
  474. _horizontal.Add ((start, length));
  475. }
  476. SetContentSize (new (_horizontal.Sum (item => item.length), 1));
  477. break;
  478. }
  479. }
  480. /// <inheritdoc/>
  481. public bool EnableForDesign ()
  482. {
  483. RadioLabels = new [] { "Option _1", "Option _2", "Option _3" };
  484. return true;
  485. }
  486. }