SelectorBase.cs 16 KB

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