RadioGroup.cs 19 KB

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