RadioGroup.cs 20 KB

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