Shortcut.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. using System.ComponentModel;
  2. using System.Reflection.Metadata;
  3. using Terminal.Gui.Analyzers.Internal.Attributes;
  4. namespace Terminal.Gui;
  5. /// <summary>
  6. /// Displays a command, help text, and a key binding. When the key is pressed, the command will be invoked. Useful for
  7. /// displaying a command in <see cref="Bar"/> such as a
  8. /// menu, toolbar, or status bar.
  9. /// </summary>
  10. /// <remarks>
  11. /// <para>
  12. /// When the user clicks on the <see cref="Shortcut"/> or presses the key
  13. /// specified by <see cref="Key"/> the <see cref="Command.Accept"/> command is invoked, causing the
  14. /// <see cref="View.Accept"/> event to be fired
  15. /// </para>
  16. /// <para>
  17. /// If <see cref="KeyBindingScope"/> is <see cref="KeyBindingScope.Application"/>, the <see cref="Command.Accept"/>
  18. /// command
  19. /// be invoked regardless of what View has focus, enabling an application-wide keyboard shortcut.
  20. /// </para>
  21. /// <para>
  22. /// A Shortcut displays the command text on the left side, the help text in the middle, and the key binding on the
  23. /// right side.
  24. /// </para>
  25. /// <para>
  26. /// The command text can be set by setting the <see cref="CommandView"/>'s Text property.
  27. /// </para>
  28. /// <para>
  29. /// The help text can be set by setting the <see cref="HelpText"/> property or by setting <see cref="View.Text"/>.
  30. /// </para>
  31. /// <para>
  32. /// The key text is set by setting the <see cref="Key"/> property.
  33. /// If the <see cref="Key"/> is <see cref="Key.Empty"/>, the <see cref="Key"/> text is not displayed.
  34. /// </para>
  35. /// </remarks>
  36. public class Shortcut : View
  37. {
  38. /// <summary>
  39. /// Creates a new instance of <see cref="Shortcut"/>;
  40. /// </summary>
  41. /// <param name="key"></param>
  42. /// <param name="commandText"></param>
  43. /// <param name="action"></param>
  44. /// <param name="helpText"></param>
  45. public Shortcut (Key key, string commandText, Action action, string helpText = null)
  46. {
  47. Id = "_shortcut";
  48. HighlightStyle = HighlightStyle.Pressed;
  49. Highlight += Shortcut_Highlight;
  50. CanFocus = true;
  51. Width = GetWidthDimAuto ();
  52. Height = Dim.Auto (DimAutoStyle.Content, 1);
  53. AddCommand (Command.HotKey, OnAccept);
  54. AddCommand (Command.Accept, OnAccept);
  55. KeyBindings.Add (KeyCode.Space, Command.Accept);
  56. KeyBindings.Add (KeyCode.Enter, Command.Accept);
  57. TitleChanged += Shortcut_TitleChanged; // This needs to be set before CommandView is set
  58. CommandView = new ()
  59. {
  60. Width = Dim.Auto (),
  61. Height = Dim.Auto (),
  62. };
  63. HelpView.Id = "_helpView";
  64. HelpView.CanFocus = false;
  65. HelpView.Text = helpText;
  66. Add (HelpView);
  67. KeyView.Id = "_keyView";
  68. KeyView.CanFocus = false;
  69. Add (KeyView);
  70. // If the user clicks anywhere on the Shortcut, other than the CommandView, invoke the Command
  71. MouseClick += Shortcut_MouseClick;
  72. HelpView.MouseClick += Shortcut_MouseClick;
  73. KeyView.MouseClick += Shortcut_MouseClick;
  74. LayoutStarted += OnLayoutStarted;
  75. Initialized += OnInitialized;
  76. if (key is null)
  77. {
  78. key = Key.Empty;
  79. }
  80. Key = key;
  81. Title = commandText;
  82. Action = action;
  83. return;
  84. void OnInitialized (object sender, EventArgs e)
  85. {
  86. SuperViewRendersLineCanvas = true;
  87. Border.ShowTitle = false;
  88. ShowHide ();
  89. // Force Width to DimAuto to calculate natural width and then set it back
  90. Dim savedDim = Width;
  91. Width = GetWidthDimAuto ();
  92. _minimumDimAutoWidth = Frame.Width;
  93. Width = savedDim;
  94. SetCommandViewDefaultLayout ();
  95. SetHelpViewDefaultLayout ();
  96. SetKeyViewDefaultLayout ();
  97. SetColors ();
  98. }
  99. // Helper to set Width consistently
  100. Dim GetWidthDimAuto ()
  101. {
  102. // TODO: PosAlign.CalculateMinDimension is a hack. Need to figure out a better way of doing this.
  103. return Dim.Auto (DimAutoStyle.Content, minimumContentDim: Dim.Func (() => PosAlign.CalculateMinDimension (0, Subviews, Dimension.Width)), maximumContentDim: Dim.Func (() => PosAlign.CalculateMinDimension (0, Subviews, Dimension.Width)));
  104. }
  105. }
  106. /// <summary>
  107. /// Creates a new instance of <see cref="Shortcut"/>.
  108. /// </summary>
  109. public Shortcut () : this (Gui.Key.Empty, string.Empty, null) { }
  110. /// <summary>
  111. /// Gets or sets the <see cref="Orientation"/> for this <see cref="Shortcut"/>. The default is
  112. /// <see cref="Orientation.Horizontal"/>, which is ideal for status bars and toolbars. If set to <see cref="Orientation.Vertical"/>,
  113. /// the Shortcut will be configured for vertical layout, which is ideal for menus.
  114. /// </summary>
  115. public Orientation Orientation { get; set; } = Orientation.Horizontal;
  116. private AlignmentModes _alignmentModes = AlignmentModes.StartToEnd | AlignmentModes.IgnoreFirstOrLast;
  117. /// <summary>
  118. /// Gets or sets the <see cref="AlignmentModes"/> for this <see cref="Shortcut"/>. The default is <see cref="AlignmentModes.StartToEnd"/>.
  119. /// </summary>
  120. public AlignmentModes AlignmentModes
  121. {
  122. get => _alignmentModes;
  123. set
  124. {
  125. _alignmentModes = value;
  126. SetCommandViewDefaultLayout ();
  127. SetHelpViewDefaultLayout ();
  128. SetKeyViewDefaultLayout ();
  129. }
  130. }
  131. // When one of the subviews is "empty" we don't want to show it. So we
  132. // Use Add/Remove. We need to be careful to add them in the right order
  133. // so Pos.Align works correctly.
  134. private void ShowHide ()
  135. {
  136. RemoveAll ();
  137. if (CommandView.Visible)
  138. {
  139. Add (CommandView);
  140. }
  141. if (HelpView.Visible && !string.IsNullOrEmpty (HelpView.Text))
  142. {
  143. Add (HelpView);
  144. }
  145. if (KeyView.Visible && Key != Key.Empty)
  146. {
  147. Add (KeyView);
  148. }
  149. }
  150. // This is used to calculate the minimum width of the Shortcut when the width is NOT Dim.Auto
  151. private int? _minimumDimAutoWidth;
  152. // When layout starts, we need to adjust the layout of the HelpView and KeyView
  153. private void OnLayoutStarted (object sender, LayoutEventArgs e)
  154. {
  155. if (Width is DimAuto widthAuto)
  156. {
  157. _minimumDimAutoWidth = Frame.Width;
  158. }
  159. else
  160. {
  161. if (string.IsNullOrEmpty (HelpView.Text))
  162. {
  163. return;
  164. }
  165. int currentWidth = Frame.Width;
  166. // If our width is smaller than the natural then reduce width of HelpView.
  167. if (currentWidth < _minimumDimAutoWidth)
  168. {
  169. int delta = _minimumDimAutoWidth.Value - currentWidth;
  170. int maxHelpWidth = int.Max (0, HelpView.Text.GetColumns () + Margin.Thickness.Horizontal - delta);
  171. switch (maxHelpWidth)
  172. {
  173. case 0:
  174. // Hide HelpView
  175. HelpView.Visible = false;
  176. HelpView.X = 0;
  177. break;
  178. case 1:
  179. // Scrunch it by removing margins
  180. HelpView.Margin.Thickness = new (0, 0, 0, 0);
  181. break;
  182. case 2:
  183. // Scrunch just the right margin
  184. var t = GetMarginThickness ();
  185. HelpView.Margin.Thickness = new (t.Right, t.Top, t.Left - 1, t.Bottom);
  186. break;
  187. default:
  188. // Default margin
  189. HelpView.Margin.Thickness = GetMarginThickness ();
  190. break;
  191. }
  192. if (maxHelpWidth > 0)
  193. {
  194. HelpView.X = Pos.Align (Alignment.End, AlignmentModes);
  195. // Leverage Dim.Auto's max:
  196. HelpView.Width = Dim.Auto (DimAutoStyle.Text, maximumContentDim: maxHelpWidth);
  197. HelpView.Visible = true;
  198. }
  199. }
  200. else
  201. {
  202. // Reset to default
  203. //SetCommandViewDefaultLayout();
  204. SetHelpViewDefaultLayout ();
  205. //SetKeyViewDefaultLayout ();
  206. }
  207. }
  208. }
  209. private Thickness GetMarginThickness ()
  210. {
  211. if (Orientation == Orientation.Vertical)
  212. {
  213. return new Thickness (1, 0, 1, 0);
  214. }
  215. else
  216. {
  217. return new Thickness (1, 0, 1, 0);
  218. }
  219. }
  220. private Color? _savedForeColor;
  221. private void Shortcut_Highlight (object sender, HighlightEventArgs e)
  222. {
  223. if (e.HighlightStyle.HasFlag (HighlightStyle.Pressed))
  224. {
  225. if (!_savedForeColor.HasValue)
  226. {
  227. _savedForeColor = base.ColorScheme.Normal.Foreground;
  228. }
  229. var cs = new ColorScheme (base.ColorScheme)
  230. {
  231. Normal = new (ColorScheme.Normal.Foreground.GetHighlightColor (), base.ColorScheme.Normal.Background)
  232. };
  233. base.ColorScheme = cs;
  234. }
  235. if (e.HighlightStyle == HighlightStyle.None && _savedForeColor.HasValue)
  236. {
  237. var cs = new ColorScheme (base.ColorScheme)
  238. {
  239. Normal = new (_savedForeColor.Value, base.ColorScheme.Normal.Background)
  240. };
  241. base.ColorScheme = cs;
  242. }
  243. SuperView?.SetNeedsDisplay ();
  244. e.Cancel = true;
  245. }
  246. private void Shortcut_MouseClick (object sender, MouseEventEventArgs e)
  247. {
  248. // When the Shortcut is clicked, we want to invoke the Command and Set focus
  249. var view = sender as View;
  250. if (!e.Handled)
  251. {
  252. // If the subview (likely CommandView) didn't handle the mouse click, invoke the command.
  253. e.Handled = InvokeCommand (Command.Accept) == true;
  254. }
  255. if (CanFocus)
  256. {
  257. SetFocus ();
  258. }
  259. }
  260. #region Command
  261. private View _commandView = new ();
  262. /// <summary>
  263. /// Gets or sets the View that displays the command text and hotkey.
  264. /// </summary>
  265. /// <remarks>
  266. /// <para>
  267. /// By default, the <see cref="View.Title"/> of the <see cref="CommandView"/> is displayed as the Shortcut's
  268. /// command text.
  269. /// </para>
  270. /// <para>
  271. /// By default, the CommandView is a <see cref="View"/> with <see cref="View.CanFocus"/> set to
  272. /// <see langword="false"/>.
  273. /// </para>
  274. /// <para>
  275. /// Setting the <see cref="CommandView"/> will add it to the <see cref="Shortcut"/> and remove any existing
  276. /// <see cref="CommandView"/>.
  277. /// </para>
  278. /// </remarks>
  279. /// <example>
  280. /// <para>
  281. /// This example illustrates how to add a <see cref="Shortcut"/> to a <see cref="StatusBar"/> that toggles the
  282. /// <see cref="Application.Force16Colors"/> property.
  283. /// </para>
  284. /// <code>
  285. /// var force16ColorsShortcut = new Shortcut
  286. /// {
  287. /// Key = Key.F6,
  288. /// KeyBindingScope = KeyBindingScope.HotKey,
  289. /// CommandView = new CheckBox { Text = "Force 16 Colors" }
  290. /// };
  291. /// var cb = force16ColorsShortcut.CommandView as CheckBox;
  292. /// cb.Checked = Application.Force16Colors;
  293. ///
  294. /// cb.Toggled += (s, e) =>
  295. /// {
  296. /// var cb = s as CheckBox;
  297. /// Application.Force16Colors = cb!.Checked == true;
  298. /// Application.Refresh();
  299. /// };
  300. /// StatusBar.Add(force16ColorsShortcut);
  301. /// </code>
  302. /// </example>
  303. public View CommandView
  304. {
  305. get => _commandView;
  306. set
  307. {
  308. if (value == null)
  309. {
  310. throw new ArgumentNullException ();
  311. }
  312. if (_commandView is { })
  313. {
  314. Remove (_commandView);
  315. _commandView?.Dispose ();
  316. }
  317. _commandView = value;
  318. _commandView.Id = "_commandView";
  319. _commandView.MouseClick += Shortcut_MouseClick;
  320. _commandView.Accept += CommandViewAccept;
  321. _commandView.HotKeyChanged += (s, e) =>
  322. {
  323. if (e.NewKey != Key.Empty)
  324. {
  325. // Add it
  326. AddKeyBindingsForHotKey (e.OldKey, e.NewKey);
  327. }
  328. };
  329. _commandView.HotKeySpecifier = new ('_');
  330. Title = _commandView.Text;
  331. SetCommandViewDefaultLayout ();
  332. SetHelpViewDefaultLayout ();
  333. SetKeyViewDefaultLayout ();
  334. ShowHide ();
  335. UpdateKeyBinding ();
  336. return;
  337. void CommandViewAccept (object sender, CancelEventArgs e)
  338. {
  339. // When the CommandView fires its Accept event, we want to act as though the
  340. // Shortcut was clicked.
  341. if (base.OnAccept () == true)
  342. {
  343. e.Cancel = true;
  344. }
  345. }
  346. }
  347. }
  348. private void SetCommandViewDefaultLayout ()
  349. {
  350. CommandView.Margin.Thickness = GetMarginThickness ();
  351. CommandView.X = Pos.Align (Alignment.End, AlignmentModes);
  352. CommandView.Y = 0;//Pos.Center ();
  353. }
  354. private void Shortcut_TitleChanged (object sender, StateEventArgs<string> e)
  355. {
  356. // If the Title changes, update the CommandView text.
  357. // This is a helper to make it easier to set the CommandView text.
  358. // CommandView is public and replaceable, but this is a convenience.
  359. _commandView.Text = Title;
  360. }
  361. #endregion Command
  362. #region Help
  363. /// <summary>
  364. /// The subview that displays the help text for the command. Internal for unit testing.
  365. /// </summary>
  366. internal View HelpView { get; } = new ();
  367. private void SetHelpViewDefaultLayout ()
  368. {
  369. HelpView.Margin.Thickness = GetMarginThickness ();
  370. HelpView.X = Pos.Align (Alignment.End, AlignmentModes);
  371. HelpView.Y = 0;//Pos.Center ();
  372. HelpView.Width = Dim.Auto (DimAutoStyle.Text);
  373. HelpView.Height = CommandView?.Visible == true ? Dim.Height (CommandView) : 1;
  374. HelpView.Visible = true;
  375. HelpView.VerticalTextAlignment = Alignment.Center;
  376. }
  377. /// <summary>
  378. /// Gets or sets the help text displayed in the middle of the Shortcut. Identical in function to <see cref="HelpText"/>
  379. /// .
  380. /// </summary>
  381. public override string Text
  382. {
  383. get => HelpView?.Text;
  384. set
  385. {
  386. if (HelpView != null)
  387. {
  388. HelpView.Text = value;
  389. ShowHide ();
  390. }
  391. }
  392. }
  393. /// <summary>
  394. /// Gets or sets the help text displayed in the middle of the Shortcut.
  395. /// </summary>
  396. public string HelpText
  397. {
  398. get => HelpView?.Text;
  399. set
  400. {
  401. if (HelpView != null)
  402. {
  403. HelpView.Text = value;
  404. ShowHide ();
  405. }
  406. }
  407. }
  408. #endregion Help
  409. #region Key
  410. private Key _key = Key.Empty;
  411. /// <summary>
  412. /// Gets or sets the <see cref="Key"/> that will be bound to the <see cref="Command.Accept"/> command.
  413. /// </summary>
  414. public Key Key
  415. {
  416. get => _key;
  417. set
  418. {
  419. if (value == null)
  420. {
  421. throw new ArgumentNullException ();
  422. }
  423. _key = value;
  424. UpdateKeyBinding ();
  425. KeyView.Text = Key == Key.Empty ? string.Empty : $"{Key}";
  426. ShowHide ();
  427. }
  428. }
  429. private KeyBindingScope _keyBindingScope = KeyBindingScope.HotKey;
  430. /// <summary>
  431. /// Gets or sets the scope for the key binding for how <see cref="Key"/> is bound to <see cref="Command"/>.
  432. /// </summary>
  433. public KeyBindingScope KeyBindingScope
  434. {
  435. get => _keyBindingScope;
  436. set
  437. {
  438. _keyBindingScope = value;
  439. UpdateKeyBinding ();
  440. }
  441. }
  442. // TODO: Make internal once Bar is done
  443. /// <summary>
  444. /// Gets the subview that displays the key. Internal for unit testing.
  445. /// </summary>
  446. public View KeyView { get; } = new ();
  447. private int _minimumKeyViewSize;
  448. /// <summary>
  449. /// </summary>
  450. public int MinimumKeyViewSize
  451. {
  452. get => _minimumKeyViewSize;
  453. set
  454. {
  455. if (value == _minimumKeyViewSize)
  456. {
  457. //return;
  458. }
  459. _minimumKeyViewSize = value;
  460. SetKeyViewDefaultLayout ();
  461. CommandView.SetNeedsLayout ();
  462. HelpView.SetNeedsLayout ();
  463. KeyView.SetNeedsLayout ();
  464. SetSubViewNeedsDisplay ();
  465. }
  466. }
  467. private int GetMinimumKeyViewSize () { return MinimumKeyViewSize; }
  468. private void SetKeyViewDefaultLayout ()
  469. {
  470. KeyView.Margin.Thickness = GetMarginThickness ();
  471. KeyView.X = Pos.Align (Alignment.End, AlignmentModes);
  472. KeyView.Y = 0;//Pos.Center ();
  473. KeyView.Width = Dim.Auto (DimAutoStyle.Text, Dim.Func (GetMinimumKeyViewSize));
  474. KeyView.Height = CommandView?.Visible == true ? Dim.Height (CommandView) : 1;
  475. KeyView.Visible = true;
  476. // Right align the text in the keyview
  477. KeyView.TextAlignment = Alignment.End;
  478. KeyView.VerticalTextAlignment = Alignment.Center;
  479. KeyView.KeyBindings.Clear ();
  480. }
  481. private void UpdateKeyBinding ()
  482. {
  483. if (Key != null)
  484. {
  485. KeyBindings.Remove (Key);
  486. KeyBindings.Add (Key, KeyBindingScope, Command.Accept);
  487. }
  488. }
  489. #endregion Key
  490. #region Accept Handling
  491. /// <summary>
  492. /// Called when the <see cref="Command.Accept"/> command is received. This
  493. /// occurs if the user clicks on the Bar with the mouse or presses the key bound to
  494. /// Command.Accept (Space by default).
  495. /// </summary>
  496. protected new bool? OnAccept ()
  497. {
  498. var handled = true;
  499. switch (KeyBindingScope)
  500. {
  501. case KeyBindingScope.Application:
  502. handled = false;
  503. break;
  504. case KeyBindingScope.Focused:
  505. // TODO: Figure this out
  506. handled = false;
  507. break;
  508. case KeyBindingScope.HotKey:
  509. _commandView.InvokeCommand (Command.HotKey);
  510. handled = false;
  511. break;
  512. }
  513. if (handled == false)
  514. {
  515. if (base.OnAccept () is false)
  516. {
  517. Action?.Invoke ();
  518. return true;
  519. }
  520. }
  521. return false;
  522. }
  523. /// <summary>
  524. /// Gets or sets the action to be invoked when the shortcut key is pressed or the shortcut is clicked on with the mouse.
  525. /// </summary>
  526. /// <remarks>
  527. /// Note, the <see cref="View.Accept"/> event is fired first, and if cancelled, the event will not be invoked.
  528. /// </remarks>
  529. [CanBeNull]
  530. public Action Action { get; set; }
  531. #endregion Accept Handling
  532. #region Focus
  533. /// <inheritdoc/>
  534. public override ColorScheme ColorScheme
  535. {
  536. get => base.ColorScheme;
  537. set
  538. {
  539. base.ColorScheme = value;
  540. SetColors ();
  541. }
  542. }
  543. /// <summary>
  544. ///
  545. /// </summary>
  546. internal void SetColors ()
  547. {
  548. // Border should match superview.
  549. Border.ColorScheme = SuperView?.ColorScheme;
  550. if (HasFocus)
  551. {
  552. // When we have focus, we invert the colors
  553. base.ColorScheme = new (base.ColorScheme)
  554. {
  555. Normal = base.ColorScheme.Focus,
  556. HotNormal = base.ColorScheme.HotFocus,
  557. HotFocus = base.ColorScheme.HotNormal,
  558. Focus = base.ColorScheme.Normal
  559. };
  560. }
  561. else
  562. {
  563. base.ColorScheme = SuperView?.ColorScheme;
  564. }
  565. // Set KeyView's colors to show "hot"
  566. if (IsInitialized && base.ColorScheme is { })
  567. {
  568. var cs = new ColorScheme (base.ColorScheme)
  569. {
  570. Normal = base.ColorScheme.HotNormal,
  571. HotNormal = base.ColorScheme.Normal
  572. };
  573. KeyView.ColorScheme = cs;
  574. }
  575. }
  576. /// <inheritdoc/>
  577. public override bool OnEnter (View view)
  578. {
  579. SetColors ();
  580. return base.OnEnter (view);
  581. }
  582. /// <inheritdoc/>
  583. public override bool OnLeave (View view)
  584. {
  585. SetColors ();
  586. return base.OnLeave (view);
  587. }
  588. #endregion Focus
  589. /// <inheritdoc />
  590. protected override void Dispose (bool disposing)
  591. {
  592. if (disposing)
  593. {
  594. if (CommandView?.IsAdded == false)
  595. {
  596. CommandView.Dispose ();
  597. }
  598. if (HelpView?.IsAdded == false)
  599. {
  600. HelpView.Dispose ();
  601. }
  602. if (KeyView?.IsAdded == false)
  603. {
  604. KeyView.Dispose ();
  605. }
  606. }
  607. base.Dispose (disposing);
  608. }
  609. }