RadioGroup.cs 18 KB

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