RadioGroup.cs 19 KB

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