Shortcut.cs 21 KB

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