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