RadioGroup.cs 17 KB

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