RadioGroup.cs 17 KB

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