2
0

SelectorBase.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. #nullable enable
  2. using System.Collections.Immutable;
  3. namespace Terminal.Gui.Views;
  4. /// <summary>
  5. /// The abstract base class for <see cref="OptionSelector{TEnum}"/> and <see cref="FlagSelector{TFlagsEnum}"/>.
  6. /// </summary>
  7. public abstract class SelectorBase : View, IOrientation
  8. {
  9. /// <summary>
  10. /// Initializes a new instance of the <see cref="SelectorBase"/> class.
  11. /// </summary>
  12. protected SelectorBase ()
  13. {
  14. CanFocus = true;
  15. Width = Dim.Auto (DimAutoStyle.Content);
  16. Height = Dim.Auto (DimAutoStyle.Content);
  17. // ReSharper disable once UseObjectOrCollectionInitializer
  18. _orientationHelper = new (this);
  19. _orientationHelper.Orientation = Orientation.Vertical;
  20. AddCommand (Command.Accept, HandleAcceptCommand);
  21. //AddCommand (Command.HotKey, HandleHotKeyCommand);
  22. //CreateSubViews ();
  23. }
  24. /// <inheritdoc />
  25. protected override bool OnClearingViewport ()
  26. {
  27. //SetAttributeForRole (HasFocus ? VisualRole.Focus : VisualRole.Normal);
  28. return base.OnClearingViewport ();
  29. }
  30. private SelectorStyles _styles;
  31. /// <summary>
  32. /// Gets or sets the styles for the flag selector.
  33. /// </summary>
  34. public SelectorStyles Styles
  35. {
  36. get => _styles;
  37. set
  38. {
  39. if (_styles == value)
  40. {
  41. return;
  42. }
  43. _styles = value;
  44. CreateSubViews ();
  45. UpdateChecked ();
  46. }
  47. }
  48. private bool? HandleAcceptCommand (ICommandContext? ctx)
  49. {
  50. if (!DoubleClickAccepts
  51. && ctx is CommandContext<MouseBinding> mouseCommandContext
  52. && mouseCommandContext.Binding.MouseEventArgs!.Flags.HasFlag (MouseFlags.Button1DoubleClicked))
  53. {
  54. return false;
  55. }
  56. return RaiseAccepting (ctx);
  57. }
  58. /// <inheritdoc />
  59. protected override bool OnHandlingHotKey (CommandEventArgs args)
  60. {
  61. // If the command did not come from a keyboard event, ignore it
  62. if (args.Context is not CommandContext<KeyBinding> keyCommandContext)
  63. {
  64. return base.OnHandlingHotKey (args);
  65. }
  66. if ((HasFocus || !CanFocus) && HotKey == keyCommandContext.Binding.Key?.NoAlt.NoCtrl.NoShift!)
  67. {
  68. // It's this.HotKey OR Another View (Label?) forwarded the hotkey command to us - Act just like `Space` (Select)
  69. return Focused?.InvokeCommand (Command.Select, args.Context) is true;
  70. }
  71. return base.OnHandlingHotKey (args);
  72. }
  73. /// <inheritdoc />
  74. protected override bool OnSelecting (CommandEventArgs args)
  75. {
  76. return base.OnSelecting (args);
  77. }
  78. private int? _value;
  79. /// <summary>
  80. /// Gets or sets the value of the selector. Will be <see langword="null"/> if no value is set.
  81. /// </summary>
  82. public virtual int? Value
  83. {
  84. get => _value;
  85. set
  86. {
  87. if (value is { } && Values is { } && !Values.Contains (value ?? -1))
  88. {
  89. throw new ArgumentOutOfRangeException (nameof (value), @$"Value must be one of the following: {string.Join (", ", Values)}");
  90. }
  91. if (_value == value)
  92. {
  93. return;
  94. }
  95. int? previousValue = _value;
  96. _value = value;
  97. UpdateChecked ();
  98. RaiseValueChanged (previousValue);
  99. }
  100. }
  101. /// <summary>
  102. /// Raised the <see cref="ValueChanged"/> event.
  103. /// </summary>
  104. /// <param name="previousValue"></param>
  105. protected void RaiseValueChanged (int? previousValue)
  106. {
  107. if (_valueField is { })
  108. {
  109. _valueField.Text = Value.ToString ();
  110. }
  111. OnValueChanged (Value, previousValue);
  112. if (Value.HasValue)
  113. {
  114. ValueChanged?.Invoke (this, new (Value.Value));
  115. }
  116. }
  117. /// <summary>
  118. /// Called when <see cref="Value"/> has changed.
  119. /// </summary>
  120. protected virtual void OnValueChanged (int? value, int? previousValue) { }
  121. /// <summary>
  122. /// Raised when <see cref="Value"/> has changed.
  123. /// </summary>
  124. public event EventHandler<EventArgs<int?>>? ValueChanged;
  125. private IReadOnlyList<int>? _values;
  126. /// <summary>
  127. /// Gets or sets the option values. If <see cref="Values"/> is <see langword="null"/>, get will
  128. /// return values based on the <see cref="Labels"/> property.
  129. /// </summary>
  130. public virtual IReadOnlyList<int>? Values
  131. {
  132. get
  133. {
  134. if (_values is { })
  135. {
  136. return _values;
  137. }
  138. // Use Labels and assume 0..Labels.Count - 1
  139. return Labels is { }
  140. ? Enumerable.Range (0, Labels.Count).ToList ()
  141. : null;
  142. }
  143. set
  144. {
  145. _values = value;
  146. // Ensure Value defaults to the first valid entry in Values if not already set
  147. if (Value is null && _values?.Any () == true)
  148. {
  149. Value = _values.First ();
  150. }
  151. CreateSubViews ();
  152. UpdateChecked ();
  153. }
  154. }
  155. private IReadOnlyList<string>? _labels;
  156. /// <summary>
  157. /// Gets or sets the list of labels for each value in <see cref="Values"/>.
  158. /// </summary>
  159. public IReadOnlyList<string>? Labels
  160. {
  161. get => _labels;
  162. set
  163. {
  164. _labels = value;
  165. CreateSubViews ();
  166. UpdateChecked ();
  167. }
  168. }
  169. /// <summary>
  170. /// Set <see cref="Values"/> and <see cref="Labels"/> from an enum type.
  171. /// </summary>
  172. /// <typeparam name="TEnum">The enum type to extract from</typeparam>
  173. /// <remarks>
  174. /// This is a convenience method that converts an enum to a dictionary of values and labels.
  175. /// The enum values are converted to int values and the enum names become the labels.
  176. /// </remarks>
  177. public void SetValuesAndLabels<TEnum> () where TEnum : struct, Enum
  178. {
  179. IEnumerable<int> values = Enum.GetValues<TEnum> ().Select (f => Convert.ToInt32 (f));
  180. Values = values.ToImmutableList ().AsReadOnly ();
  181. Labels = Enum.GetNames<TEnum> ();
  182. }
  183. private bool _assignHotKeys;
  184. /// <summary>
  185. /// If <see langword="true"/> each label will automatically be assigned a unique hotkey.
  186. /// <see cref="UsedHotKeys"/> will be used to ensure unique keys are assigned. Set <see cref="UsedHotKeys"/>
  187. /// before setting <see cref="Labels"/> with any hotkeys that may conflict with other Views.
  188. /// </summary>
  189. public bool AssignHotKeys
  190. {
  191. get => _assignHotKeys;
  192. set
  193. {
  194. if (_assignHotKeys == value)
  195. {
  196. return;
  197. }
  198. _assignHotKeys = value;
  199. CreateSubViews ();
  200. UpdateChecked ();
  201. }
  202. }
  203. /// <summary>
  204. /// Gets or sets the set of hotkeys that are already used by labels or should not be used when
  205. /// <see cref="AssignHotKeys"/> is enabled.
  206. /// <para>
  207. /// This property is used to ensure that automatically assigned hotkeys do not conflict with
  208. /// hotkeys used elsewhere in the application. Set <see cref="UsedHotKeys"/> before setting
  209. /// <see cref="Labels"/> if there are hotkeys that may conflict with other views.
  210. /// </para>
  211. /// </summary>
  212. public HashSet<Key> UsedHotKeys { get; set; } = [];
  213. private TextField? _valueField;
  214. /// <summary>
  215. /// Creates the subviews for this selector.
  216. /// </summary>
  217. public void CreateSubViews ()
  218. {
  219. foreach (View sv in RemoveAll ())
  220. {
  221. if (AssignHotKeys)
  222. {
  223. UsedHotKeys.Remove (sv.HotKey);
  224. }
  225. sv.Dispose ();
  226. }
  227. if (Labels is null)
  228. {
  229. return;
  230. }
  231. if (Labels?.Count != Values?.Count)
  232. {
  233. return;
  234. }
  235. OnCreatingSubViews ();
  236. for (var index = 0; index < Labels?.Count; index++)
  237. {
  238. Add (CreateCheckBox (Labels.ElementAt (index), Values!.ElementAt (index)));
  239. }
  240. if (Styles.HasFlag (SelectorStyles.ShowValue))
  241. {
  242. _valueField = new ()
  243. {
  244. Id = "valueField",
  245. Text = Value.ToString (),
  246. // TODO: Don't hardcode this; base it on max Value
  247. Width = 5,
  248. ReadOnly = true
  249. };
  250. Add (_valueField);
  251. }
  252. OnCreatedSubViews ();
  253. AssignUniqueHotKeys ();
  254. SetLayout ();
  255. }
  256. /// <summary>
  257. /// Called before <see cref="CreateSubViews"/> creates the default subviews (Checkboxes and ValueField).
  258. /// </summary>
  259. protected virtual void OnCreatingSubViews () { }
  260. /// <summary>
  261. /// Called after <see cref="CreateSubViews"/> creates the default subviews (Checkboxes and ValueField).
  262. /// </summary>
  263. protected virtual void OnCreatedSubViews () { }
  264. /// <summary>
  265. /// INTERNAL: Creates a checkbox subview
  266. /// </summary>
  267. protected CheckBox CreateCheckBox (string label, int value)
  268. {
  269. var checkbox = new CheckBox
  270. {
  271. CanFocus = true,
  272. Title = label,
  273. Id = label,
  274. Data = value,
  275. HighlightStates = MouseState.In,
  276. };
  277. return checkbox;
  278. }
  279. /// <summary>
  280. /// Assigns unique hotkeys to the labels of the subviews created by <see cref="CreateSubViews"/>.
  281. /// </summary>
  282. private void AssignUniqueHotKeys ()
  283. {
  284. if (!AssignHotKeys || Labels is null)
  285. {
  286. return;
  287. }
  288. foreach (View subView in SubViews)
  289. {
  290. string label = subView.Title ?? string.Empty;
  291. // Check if there's already a hotkey defined
  292. if (TextFormatter.FindHotKey (label, HotKeySpecifier, out int hotKeyPos, out Key existingHotKey))
  293. {
  294. // Label already has a hotkey - preserve it if available
  295. if (!UsedHotKeys.Contains (existingHotKey))
  296. {
  297. subView.HotKey = existingHotKey;
  298. UsedHotKeys.Add (existingHotKey);
  299. continue; // Keep existing hotkey specifier in label
  300. }
  301. else
  302. {
  303. // Existing hotkey is already used, remove it and assign new one
  304. label = TextFormatter.RemoveHotKeySpecifier (label, hotKeyPos, HotKeySpecifier);
  305. }
  306. }
  307. // Assign a new hotkey
  308. Rune [] runes = label.EnumerateRunes ().ToArray ();
  309. for (var i = 0; i < runes.Count (); i++)
  310. {
  311. Rune lower = Rune.ToLowerInvariant (runes [i]);
  312. var newKey = new Key (lower.Value);
  313. if (UsedHotKeys.Contains (newKey))
  314. {
  315. continue;
  316. }
  317. if (!newKey.IsValid || newKey == Key.Empty || newKey == Key.Space || Rune.IsControl (newKey.AsRune))
  318. {
  319. continue;
  320. }
  321. subView.Title = label.Insert (i, HotKeySpecifier.ToString ());
  322. subView.HotKey = newKey;
  323. UsedHotKeys.Add (subView.HotKey);
  324. break;
  325. }
  326. }
  327. }
  328. private int _horizontalSpace = 2;
  329. /// <summary>
  330. /// Gets or sets the horizontal space for this <see cref="OptionSelector"/> if the <see cref="Orientation"/> is
  331. /// <see cref="Orientation.Horizontal"/>
  332. /// </summary>
  333. public int HorizontalSpace
  334. {
  335. get => _horizontalSpace;
  336. set
  337. {
  338. if (_horizontalSpace != value)
  339. {
  340. _horizontalSpace = value;
  341. SetLayout ();
  342. // Pos.Align requires extra layout; good practice to call
  343. // Layout to ensure Pos.Align gets updated
  344. // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will
  345. // TODO: negate need for this hack
  346. Layout ();
  347. }
  348. }
  349. }
  350. private void SetLayout ()
  351. {
  352. int maxNaturalCheckBoxWidth = 0;
  353. if (Values?.Count > 0 && Orientation == Orientation.Vertical)
  354. {
  355. // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will
  356. // TODO: negate need for this hack
  357. maxNaturalCheckBoxWidth = SubViews.OfType<CheckBox> ().Max (
  358. v =>
  359. {
  360. v.SetRelativeLayout (Application.Screen.Size);
  361. v.Layout ();
  362. return v.Frame.Width;
  363. });
  364. }
  365. for (var i = 0; i < SubViews.Count; i++)
  366. {
  367. if (Orientation == Orientation.Vertical)
  368. {
  369. SubViews.ElementAt (i).X = 0;
  370. SubViews.ElementAt (i).Y = Pos.Align (Alignment.Start, AlignmentModes.StartToEnd);
  371. SubViews.ElementAt (i).Margin!.Thickness = new (0);
  372. SubViews.ElementAt (i).Width = Dim.Func (_ => Math.Max (Viewport.Width, maxNaturalCheckBoxWidth));
  373. }
  374. else
  375. {
  376. SubViews.ElementAt (i).X = Pos.Align (Alignment.Start, AlignmentModes.StartToEnd);
  377. SubViews.ElementAt (i).Y = 0;
  378. SubViews.ElementAt (i).Margin!.Thickness = new (0, 0, (i < SubViews.Count - 1) ? _horizontalSpace : 0, 0);
  379. SubViews.ElementAt (i).Width = Dim.Auto ();
  380. }
  381. }
  382. }
  383. /// <summary>
  384. /// Called when the checked state of the checkboxes needs to be updated.
  385. /// </summary>
  386. /// <exception cref="InvalidOperationException"></exception>
  387. public abstract void UpdateChecked ();
  388. /// <summary>
  389. /// Gets or sets whether double-clicking on an Item will cause the <see cref="View.Accepting"/> event to be
  390. /// raised.
  391. /// </summary>
  392. /// <remarks>
  393. /// <para>
  394. /// If <see langword="false"/> and Accept is not handled, the Accept event on the <see cref="View.SuperView"/> will
  395. /// be raised. The default is
  396. /// <see langword="true"/>.
  397. /// </para>
  398. /// </remarks>
  399. public bool DoubleClickAccepts { get; set; } = true;
  400. #region IOrientation
  401. /// <summary>
  402. /// Gets or sets the <see cref="Orientation"/> for this <see cref="SelectorBase"/>. The default is
  403. /// <see cref="Orientation.Vertical"/>.
  404. /// </summary>
  405. public Orientation Orientation
  406. {
  407. get => _orientationHelper.Orientation;
  408. set => _orientationHelper.Orientation = value;
  409. }
  410. private readonly OrientationHelper _orientationHelper;
  411. #pragma warning disable CS0067 // The event is never used
  412. /// <inheritdoc/>
  413. public event EventHandler<CancelEventArgs<Orientation>>? OrientationChanging;
  414. /// <inheritdoc/>
  415. public event EventHandler<EventArgs<Orientation>>? OrientationChanged;
  416. #pragma warning restore CS0067 // The event is never used
  417. /// <summary>Called when <see cref="Orientation"/> has changed.</summary>
  418. /// <param name="newOrientation"></param>
  419. public void OnOrientationChanged (Orientation newOrientation)
  420. {
  421. SetLayout ();
  422. // Pos.Align requires extra layout; good practice to call
  423. // Layout to ensure Pos.Align gets updated
  424. // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/3951 which, if fixed, will
  425. // TODO: negate need for this hack
  426. Layout ();
  427. }
  428. #endregion IOrientation
  429. }