TabView.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. namespace Terminal.Gui.Views;
  2. /// <summary>Control that hosts multiple sub views, presenting a single one at once.</summary>
  3. public class TabView : View
  4. {
  5. /// <summary>The default <see cref="MaxTabTextWidth"/> to set on new <see cref="TabView"/> controls.</summary>
  6. public const uint DefaultMaxTabTextWidth = 30;
  7. /// <summary>
  8. /// This sub view is the main client area of the current tab. It hosts the <see cref="Tab.View"/> of the tab, the
  9. /// <see cref="SelectedTab"/>.
  10. /// </summary>
  11. private readonly View _containerView;
  12. private readonly List<Tab> _tabs = new ();
  13. /// <summary>This sub view is the 2 or 3 line control that represents the actual tabs themselves.</summary>
  14. private readonly TabRow _tabsBar;
  15. private Tab? _selectedTab;
  16. internal Tab []? _tabLocations;
  17. private int _tabScrollOffset;
  18. /// <summary>Initializes a <see cref="TabView"/> class.</summary>
  19. public TabView ()
  20. {
  21. CanFocus = true;
  22. TabStop = TabBehavior.TabStop; // Because TabView has focusable subviews, it must be a TabGroup
  23. _tabsBar = new TabRow (this);
  24. _containerView = new ();
  25. ApplyStyleChanges ();
  26. base.Add (_tabsBar);
  27. base.Add (_containerView);
  28. // Things this view knows how to do
  29. AddCommand (Command.Left, () => SwitchTabBy (-1));
  30. AddCommand (Command.Right, () => SwitchTabBy (1));
  31. AddCommand (
  32. Command.LeftStart,
  33. () =>
  34. {
  35. TabScrollOffset = 0;
  36. SelectedTab = Tabs.FirstOrDefault ()!;
  37. return true;
  38. }
  39. );
  40. AddCommand (
  41. Command.RightEnd,
  42. () =>
  43. {
  44. TabScrollOffset = Tabs.Count - 1;
  45. SelectedTab = Tabs.LastOrDefault ()!;
  46. return true;
  47. }
  48. );
  49. AddCommand (
  50. Command.PageDown,
  51. () =>
  52. {
  53. TabScrollOffset += _tabLocations!.Length;
  54. SelectedTab = Tabs.ElementAt (TabScrollOffset);
  55. return true;
  56. }
  57. );
  58. AddCommand (
  59. Command.PageUp,
  60. () =>
  61. {
  62. TabScrollOffset -= _tabLocations!.Length;
  63. SelectedTab = Tabs.ElementAt (TabScrollOffset);
  64. return true;
  65. }
  66. );
  67. AddCommand (
  68. Command.Up,
  69. () =>
  70. {
  71. if (_style.TabsOnBottom)
  72. {
  73. if (_tabsBar is { HasFocus: true } && _containerView.CanFocus)
  74. {
  75. _containerView.SetFocus ();
  76. return true;
  77. }
  78. }
  79. else
  80. {
  81. if (_containerView is { HasFocus: true })
  82. {
  83. var mostFocused = _containerView.MostFocused;
  84. if (mostFocused is { })
  85. {
  86. for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) - 1; i > -1; i--)
  87. {
  88. var view = mostFocused.SuperView?.SubViews.ElementAt ((int)i);
  89. if (view is { CanFocus: true, Enabled: true, Visible: true })
  90. {
  91. // Let runnable handle it
  92. return false;
  93. }
  94. }
  95. }
  96. SelectedTab?.SetFocus ();
  97. return true;
  98. }
  99. }
  100. return false;
  101. }
  102. );
  103. AddCommand (
  104. Command.Down,
  105. () =>
  106. {
  107. if (_style.TabsOnBottom)
  108. {
  109. if (_containerView is { HasFocus: true })
  110. {
  111. var mostFocused = _containerView.MostFocused;
  112. if (mostFocused is { })
  113. {
  114. for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) + 1; i < mostFocused.SuperView?.SubViews.Count; i++)
  115. {
  116. var view = mostFocused.SuperView?.SubViews.ElementAt ((int)i);
  117. if (view is { CanFocus: true, Enabled: true, Visible: true })
  118. {
  119. // Let runnable handle it
  120. return false;
  121. }
  122. }
  123. }
  124. SelectedTab?.SetFocus ();
  125. return true;
  126. }
  127. }
  128. else
  129. {
  130. if (_tabsBar is { HasFocus: true } && _containerView.CanFocus)
  131. {
  132. _containerView.SetFocus ();
  133. return true;
  134. }
  135. }
  136. return false;
  137. }
  138. );
  139. // Default keybindings for this view
  140. KeyBindings.Add (Key.CursorLeft, Command.Left);
  141. KeyBindings.Add (Key.CursorRight, Command.Right);
  142. KeyBindings.Add (Key.Home, Command.LeftStart);
  143. KeyBindings.Add (Key.End, Command.RightEnd);
  144. KeyBindings.Add (Key.PageDown, Command.PageDown);
  145. KeyBindings.Add (Key.PageUp, Command.PageUp);
  146. KeyBindings.Add (Key.CursorUp, Command.Up);
  147. KeyBindings.Add (Key.CursorDown, Command.Down);
  148. }
  149. /// <summary>
  150. /// The maximum number of characters to render in a Tab header. This prevents one long tab from pushing out all
  151. /// the others.
  152. /// </summary>
  153. public uint MaxTabTextWidth { get; set; } = DefaultMaxTabTextWidth;
  154. // This is needed to hold initial value because it may change during the setter process
  155. private bool _selectedTabHasFocus;
  156. /// <summary>The currently selected member of <see cref="Tabs"/> chosen by the user.</summary>
  157. /// <value></value>
  158. public Tab? SelectedTab
  159. {
  160. get => _selectedTab;
  161. set
  162. {
  163. if (value == _selectedTab)
  164. {
  165. return;
  166. }
  167. Tab? old = _selectedTab;
  168. _selectedTabHasFocus = old is { } && (old.HasFocus || !_containerView.CanFocus);
  169. if (_selectedTab is { })
  170. {
  171. if (_selectedTab.View is { })
  172. {
  173. _selectedTab.View.CanFocusChanged -= ContainerViewCanFocus!;
  174. // remove old content
  175. _containerView.Remove (_selectedTab.View);
  176. }
  177. }
  178. _selectedTab = value;
  179. // add new content
  180. if (_selectedTab?.View != null)
  181. {
  182. _selectedTab.View.CanFocusChanged += ContainerViewCanFocus!;
  183. _containerView.Add (_selectedTab.View);
  184. }
  185. ContainerViewCanFocus (null!, null!);
  186. EnsureSelectedTabIsVisible ();
  187. if (old != _selectedTab)
  188. {
  189. if (TabCanSetFocus ())
  190. {
  191. SelectedTab?.SetFocus ();
  192. }
  193. OnSelectedTabChanged (old!, _selectedTab!);
  194. }
  195. SetNeedsLayout ();
  196. }
  197. }
  198. private bool TabCanSetFocus ()
  199. {
  200. #pragma warning disable CS8629 // Nullable value type may be null.
  201. return IsInitialized && SelectedTab is { } && (HasFocus || (bool)_containerView?.HasFocus) && (_selectedTabHasFocus || !_containerView.CanFocus);
  202. #pragma warning restore CS8629 // Nullable value type may be null.
  203. }
  204. private void ContainerViewCanFocus (object sender, EventArgs eventArgs)
  205. {
  206. _containerView.CanFocus = _containerView.SubViews.Count (v => v.CanFocus) > 0;
  207. }
  208. private TabStyle _style = new ();
  209. /// <summary>Render choices for how to display tabs. After making changes, call <see cref="ApplyStyleChanges()"/>.</summary>
  210. /// <value></value>
  211. public TabStyle Style
  212. {
  213. get => _style;
  214. set
  215. {
  216. if (_style == value)
  217. {
  218. return;
  219. }
  220. _style = value;
  221. SetNeedsLayout ();
  222. }
  223. }
  224. /// <summary>All tabs currently hosted by the control.</summary>
  225. /// <value></value>
  226. public IReadOnlyCollection<Tab> Tabs => _tabs.AsReadOnly ();
  227. /// <summary>When there are too many tabs to render, this indicates the first tab to render on the screen.</summary>
  228. /// <value></value>
  229. public int TabScrollOffset
  230. {
  231. get => _tabScrollOffset;
  232. set
  233. {
  234. _tabScrollOffset = EnsureValidScrollOffsets (value);
  235. SetNeedsLayout ();
  236. }
  237. }
  238. /// <summary>Adds the given <paramref name="tab"/> to <see cref="Tabs"/>.</summary>
  239. /// <param name="tab"></param>
  240. /// <param name="andSelect">True to make the newly added Tab the <see cref="SelectedTab"/>.</param>
  241. public void AddTab (Tab tab, bool andSelect)
  242. {
  243. if (_tabs.Contains (tab))
  244. {
  245. return;
  246. }
  247. _tabs.Add (tab);
  248. _tabsBar.Add (tab);
  249. if (SelectedTab is null || andSelect)
  250. {
  251. SelectedTab = tab;
  252. EnsureSelectedTabIsVisible ();
  253. tab.View?.SetFocus ();
  254. }
  255. SetNeedsLayout ();
  256. }
  257. /// <summary>
  258. /// Updates the control to use the latest state settings in <see cref="Style"/>. This can change the size of the
  259. /// client area of the tab (for rendering the selected tab's content). This method includes a call to
  260. /// <see cref="View.SetNeedsDraw()"/>.
  261. /// </summary>
  262. public void ApplyStyleChanges ()
  263. {
  264. _containerView.BorderStyle = Style.ShowBorder ? LineStyle.Single : LineStyle.None;
  265. _containerView.Width = Dim.Fill ();
  266. if (Style.TabsOnBottom)
  267. {
  268. // Tabs are along the bottom so just dodge the border
  269. if (Style.ShowBorder)
  270. {
  271. _containerView.Border!.Thickness = new Thickness (1, 1, 1, 0);
  272. }
  273. _containerView.Y = 0;
  274. int tabHeight = GetTabHeight (false);
  275. // Fill client area leaving space at bottom for tabs
  276. _containerView.Height = Dim.Fill (tabHeight);
  277. _tabsBar.Height = tabHeight;
  278. _tabsBar.Y = Pos.Bottom (_containerView);
  279. }
  280. else
  281. {
  282. // Tabs are along the top
  283. if (Style.ShowBorder)
  284. {
  285. _containerView.Border!.Thickness = new Thickness (1, 0, 1, 1);
  286. }
  287. _tabsBar.Y = 0;
  288. int tabHeight = GetTabHeight (true);
  289. //move content down to make space for tabs
  290. _containerView.Y = Pos.Bottom (_tabsBar);
  291. // Fill client area leaving space at bottom for border
  292. _containerView.Height = Dim.Fill ();
  293. // The top tab should be 2 or 3 rows high and on the top
  294. _tabsBar.Height = tabHeight;
  295. // Should be able to just use 0 but switching between top/bottom tabs repeatedly breaks in ValidatePosDim if just using the absolute value 0
  296. }
  297. SetNeedsLayout ();
  298. }
  299. /// <inheritdoc />
  300. protected override void OnViewportChanged (DrawEventArgs e)
  301. {
  302. _tabLocations = CalculateViewport (Viewport).ToArray ();
  303. base.OnViewportChanged (e);
  304. }
  305. /// <summary>Updates <see cref="TabScrollOffset"/> to ensure that <see cref="SelectedTab"/> is visible.</summary>
  306. public void EnsureSelectedTabIsVisible ()
  307. {
  308. if (!IsInitialized || SelectedTab is null)
  309. {
  310. return;
  311. }
  312. // if current viewport does not include the selected tab
  313. if (!CalculateViewport (Viewport).Any (t => Equals (SelectedTab, t)))
  314. {
  315. // Set scroll offset so the first tab rendered is the
  316. TabScrollOffset = Math.Max (0, Tabs.IndexOf (SelectedTab));
  317. }
  318. }
  319. /// <summary>Updates <see cref="TabScrollOffset"/> to be a valid index of <see cref="Tabs"/>.</summary>
  320. /// <param name="value">The value to validate.</param>
  321. /// <remarks>Changes will not be immediately visible in the display until you call <see cref="View.SetNeedsDraw()"/>.</remarks>
  322. /// <returns>The valid <see cref="TabScrollOffset"/> for the given value.</returns>
  323. public int EnsureValidScrollOffsets (int value) { return Math.Max (Math.Min (value, Tabs.Count - 1), 0); }
  324. /// <inheritdoc />
  325. protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView)
  326. {
  327. if (SelectedTab is { HasFocus: false } && !_containerView.CanFocus && focusedView == this)
  328. {
  329. SelectedTab?.SetFocus ();
  330. return;
  331. }
  332. base.OnHasFocusChanged (newHasFocus, previousFocusedView, focusedView);
  333. }
  334. /// <summary>
  335. /// Removes the given <paramref name="tab"/> from <see cref="Tabs"/>. Caller is responsible for disposing the
  336. /// tab's hosted <see cref="Tab.View"/> if appropriate.
  337. /// </summary>
  338. /// <param name="tab"></param>
  339. public void RemoveTab (Tab? tab)
  340. {
  341. if (tab is null || !_tabs.Contains (tab))
  342. {
  343. return;
  344. }
  345. // what tab was selected before closing
  346. int idx = _tabs.IndexOf (tab);
  347. _tabs.Remove (tab);
  348. // if the currently selected tab is no longer a member of Tabs
  349. if (SelectedTab is null || !Tabs.Contains (SelectedTab))
  350. {
  351. // select the tab closest to the one that disappeared
  352. int toSelect = Math.Max (idx - 1, 0);
  353. if (toSelect < Tabs.Count)
  354. {
  355. SelectedTab = Tabs.ElementAt (toSelect);
  356. }
  357. else
  358. {
  359. SelectedTab = Tabs.LastOrDefault ();
  360. }
  361. }
  362. EnsureSelectedTabIsVisible ();
  363. SetNeedsLayout ();
  364. }
  365. /// <summary>Event for when <see cref="SelectedTab"/> changes.</summary>
  366. public event EventHandler<TabChangedEventArgs>? SelectedTabChanged;
  367. /// <summary>
  368. /// Changes the <see cref="SelectedTab"/> by the given <paramref name="amount"/>. Positive for right, negative for
  369. /// left. If no tab is currently selected then the first tab will become selected.
  370. /// </summary>
  371. /// <param name="amount"></param>
  372. public bool SwitchTabBy (int amount)
  373. {
  374. if (Tabs.Count == 0)
  375. {
  376. return false;
  377. }
  378. // if there is only one tab anyway or nothing is selected
  379. if (Tabs.Count == 1 || SelectedTab is null)
  380. {
  381. SelectedTab = Tabs.ElementAt (0);
  382. return SelectedTab is { };
  383. }
  384. int currentIdx = Tabs.IndexOf (SelectedTab);
  385. // Currently selected tab has vanished!
  386. if (currentIdx == -1)
  387. {
  388. SelectedTab = Tabs.ElementAt (0);
  389. return true;
  390. }
  391. int newIdx = Math.Max (0, Math.Min (currentIdx + amount, Tabs.Count - 1));
  392. if (newIdx == currentIdx)
  393. {
  394. return false;
  395. }
  396. SelectedTab = _tabs [newIdx];
  397. EnsureSelectedTabIsVisible ();
  398. return true;
  399. }
  400. /// <summary>
  401. /// Event fired when a <see cref="Tab"/> is clicked. Can be used to cancel navigation, show context menu (e.g. on
  402. /// right click) etc.
  403. /// </summary>
  404. public event EventHandler<TabMouseEventArgs>? TabClicked;
  405. /// <summary>Disposes the control and all <see cref="Tabs"/>.</summary>
  406. /// <param name="disposing"></param>
  407. protected override void Dispose (bool disposing)
  408. {
  409. base.Dispose (disposing);
  410. // The selected tab will automatically be disposed but
  411. // any tabs not visible will need to be manually disposed
  412. foreach (Tab tab in Tabs)
  413. {
  414. if (!Equals (SelectedTab, tab))
  415. {
  416. tab.View?.Dispose ();
  417. }
  418. }
  419. }
  420. /// <summary>Raises the <see cref="SelectedTabChanged"/> event.</summary>
  421. protected virtual void OnSelectedTabChanged (Tab oldTab, Tab newTab)
  422. {
  423. SelectedTabChanged?.Invoke (this, new TabChangedEventArgs (oldTab, newTab));
  424. }
  425. /// <summary>Returns which tabs to render at each x location.</summary>
  426. /// <returns></returns>
  427. internal IEnumerable<Tab> CalculateViewport (Rectangle bounds)
  428. {
  429. UnSetCurrentTabs ();
  430. var i = 1;
  431. View? prevTab = null;
  432. // Starting at the first or scrolled to tab
  433. foreach (Tab tab in Tabs.Skip (TabScrollOffset))
  434. {
  435. if (prevTab is { })
  436. {
  437. tab.X = Pos.Right (prevTab) - 1;
  438. }
  439. else
  440. {
  441. tab.X = 0;
  442. }
  443. tab.Y = 0;
  444. // while there is space for the tab
  445. int tabTextWidth = tab.DisplayText.EnumerateRunes ().Sum (c => c.GetColumns ());
  446. // The maximum number of characters to use for the tab name as specified
  447. // by the user (MaxTabTextWidth). But not more than the width of the view
  448. // or we won't even be able to render a single tab!
  449. long maxWidth = Math.Max (0, Math.Min (bounds.Width - 3, MaxTabTextWidth));
  450. tab.Width = 2;
  451. tab.Height = Style.ShowTopLine ? 3 : 2;
  452. // if tab view is width <= 3 don't render any tabs
  453. if (maxWidth == 0)
  454. {
  455. tab.Visible = true;
  456. tab.Activating += Tab_Selecting!;
  457. tab.Border!.Activating += Tab_Selecting!;
  458. yield return tab;
  459. break;
  460. }
  461. if (tabTextWidth > maxWidth)
  462. {
  463. tab.Text = tab.DisplayText.Substring (0, (int)maxWidth);
  464. tabTextWidth = (int)maxWidth;
  465. }
  466. else
  467. {
  468. tab.Text = tab.DisplayText;
  469. }
  470. tab.Width = Math.Max (tabTextWidth + 2, 1);
  471. tab.Height = Style.ShowTopLine ? 3 : 2;
  472. // if there is not enough space for this tab
  473. if (i + tabTextWidth >= bounds.Width)
  474. {
  475. tab.Visible = false;
  476. break;
  477. }
  478. // there is enough space!
  479. tab.Visible = true;
  480. tab.Activating += Tab_Selecting!;
  481. tab.Border!.Activating += Tab_Selecting!;
  482. yield return tab;
  483. prevTab = tab;
  484. i += tabTextWidth + 1;
  485. }
  486. if (TabCanSetFocus ())
  487. {
  488. SelectedTab?.SetFocus ();
  489. }
  490. else if (HasFocus)
  491. {
  492. SelectedTab?.View?.SetFocus ();
  493. }
  494. }
  495. /// <summary>
  496. /// Returns the number of rows occupied by rendering the tabs, this depends on <see cref="TabStyle.ShowTopLine"/>
  497. /// and can be 0 (e.g. if <see cref="TabStyle.TabsOnBottom"/> and you ask for <paramref name="top"/>).
  498. /// </summary>
  499. /// <param name="top">True to measure the space required at the top of the control, false to measure space at the bottom.</param>
  500. /// .
  501. /// <returns></returns>
  502. private int GetTabHeight (bool top)
  503. {
  504. if (top && Style.TabsOnBottom)
  505. {
  506. return 0;
  507. }
  508. if (!top && !Style.TabsOnBottom)
  509. {
  510. return 0;
  511. }
  512. return Style.ShowTopLine ? 3 : 2;
  513. }
  514. internal void Tab_Selecting (object? sender, CommandEventArgs e)
  515. {
  516. if (e.Context is CommandContext<MouseBinding> { Binding.MouseEventArgs: { } mouseArgs })
  517. {
  518. e.Handled = _tabsBar.NewMouseEvent (mouseArgs) == true;
  519. }
  520. }
  521. private void UnSetCurrentTabs ()
  522. {
  523. if (_tabLocations is null)
  524. {
  525. // Ensures unset any visible tab prior to TabScrollOffset
  526. for (int i = 0; i < TabScrollOffset; i++)
  527. {
  528. Tab tab = Tabs.ElementAt (i);
  529. if (tab.Visible)
  530. {
  531. tab.Activating -= Tab_Selecting!;
  532. tab.Border!.Activating -= Tab_Selecting!;
  533. tab.Visible = false;
  534. }
  535. }
  536. }
  537. else if (_tabLocations is { })
  538. {
  539. foreach (Tab tabToRender in _tabLocations)
  540. {
  541. tabToRender.Activating -= Tab_Selecting!;
  542. tabToRender.Border!.Activating -= Tab_Selecting!;
  543. tabToRender.Visible = false;
  544. }
  545. _tabLocations = null;
  546. }
  547. }
  548. /// <summary>Raises the <see cref="TabClicked"/> event.</summary>
  549. /// <param name="tabMouseEventArgs"></param>
  550. internal virtual void OnTabClicked (TabMouseEventArgs tabMouseEventArgs) { TabClicked?.Invoke (this, tabMouseEventArgs); }
  551. }