#nullable enable namespace Terminal.Gui; /// Control that hosts multiple sub views, presenting a single one at once. public class TabView : View { /// The default to set on new controls. public const uint DefaultMaxTabTextWidth = 30; /// /// This sub view is the main client area of the current tab. It hosts the of the tab, the /// . /// private readonly View _containerView; private readonly List _tabs = new (); /// This sub view is the 2 or 3 line control that represents the actual tabs themselves. private readonly TabRowView _tabsBar; private Tab? _selectedTab; internal Tab []? _tabLocations; private int _tabScrollOffset; /// Initializes a class. public TabView () { CanFocus = true; TabStop = TabBehavior.TabStop; // Because TabView has focusable subviews, it must be a TabGroup _tabsBar = new TabRowView (this); _containerView = new (); ApplyStyleChanges (); base.Add (_tabsBar); base.Add (_containerView); // Things this view knows how to do AddCommand (Command.Left, () => SwitchTabBy (-1)); AddCommand (Command.Right, () => SwitchTabBy (1)); AddCommand ( Command.LeftStart, () => { TabScrollOffset = 0; SelectedTab = Tabs.FirstOrDefault ()!; return true; } ); AddCommand ( Command.RightEnd, () => { TabScrollOffset = Tabs.Count - 1; SelectedTab = Tabs.LastOrDefault ()!; return true; } ); AddCommand ( Command.PageDown, () => { TabScrollOffset += _tabLocations!.Length; SelectedTab = Tabs.ElementAt (TabScrollOffset); return true; } ); AddCommand ( Command.PageUp, () => { TabScrollOffset -= _tabLocations!.Length; SelectedTab = Tabs.ElementAt (TabScrollOffset); return true; } ); // Default keybindings for this view KeyBindings.Add (Key.CursorLeft, Command.Left); KeyBindings.Add (Key.CursorRight, Command.Right); KeyBindings.Add (Key.Home, Command.LeftStart); KeyBindings.Add (Key.End, Command.RightEnd); KeyBindings.Add (Key.PageDown, Command.PageDown); KeyBindings.Add (Key.PageUp, Command.PageUp); } /// /// The maximum number of characters to render in a Tab header. This prevents one long tab from pushing out all /// the others. /// public uint MaxTabTextWidth { get; set; } = DefaultMaxTabTextWidth; // This is needed to hold initial value because it may change during the setter process private bool _selectedTabHasFocus; /// The currently selected member of chosen by the user. /// public Tab? SelectedTab { get => _selectedTab; set { if (value == _selectedTab) { return; } Tab? old = _selectedTab; _selectedTabHasFocus = old is { } && (old.HasFocus || !_containerView.CanFocus); if (_selectedTab is { }) { if (_selectedTab.View is { }) { _selectedTab.View.CanFocusChanged -= ContainerViewCanFocus!; // remove old content _containerView.Remove (_selectedTab.View); } } _selectedTab = value; // add new content if (_selectedTab?.View != null) { _selectedTab.View.CanFocusChanged += ContainerViewCanFocus!; _containerView.Add (_selectedTab.View); } ContainerViewCanFocus (null!, null!); EnsureSelectedTabIsVisible (); if (old != _selectedTab) { if (TabCanSetFocus ()) { SelectedTab?.SetFocus (); } OnSelectedTabChanged (old!, _selectedTab!); } SetNeedsLayout (); } } private bool TabCanSetFocus () { return IsInitialized && SelectedTab is { } && (_selectedTabHasFocus || !_containerView.CanFocus); } private void ContainerViewCanFocus (object sender, EventArgs eventArgs) { _containerView.CanFocus = _containerView.Subviews.Count (v => v.CanFocus) > 0; } private TabStyle _style = new (); /// Render choices for how to display tabs. After making changes, call . /// public TabStyle Style { get => _style; set { if (_style == value) { return; } _style = value; SetNeedsLayout (); } } /// All tabs currently hosted by the control. /// public IReadOnlyCollection Tabs => _tabs.AsReadOnly (); /// When there are too many tabs to render, this indicates the first tab to render on the screen. /// public int TabScrollOffset { get => _tabScrollOffset; set { _tabScrollOffset = EnsureValidScrollOffsets (value); SetNeedsLayout (); } } /// Adds the given to . /// /// True to make the newly added Tab the . public void AddTab (Tab tab, bool andSelect) { if (_tabs.Contains (tab)) { return; } _tabs.Add (tab); _tabsBar.Add (tab); if (SelectedTab is null || andSelect) { SelectedTab = tab; EnsureSelectedTabIsVisible (); tab.View?.SetFocus (); } SetNeedsLayout (); } /// /// Updates the control to use the latest state settings in . This can change the size of the /// client area of the tab (for rendering the selected tab's content). This method includes a call to /// . /// public void ApplyStyleChanges () { _containerView.BorderStyle = Style.ShowBorder ? LineStyle.Single : LineStyle.None; _containerView.Width = Dim.Fill (); if (Style.TabsOnBottom) { // Tabs are along the bottom so just dodge the border if (Style.ShowBorder) { _containerView.Border!.Thickness = new Thickness (1, 1, 1, 0); } _containerView.Y = 0; int tabHeight = GetTabHeight (false); // Fill client area leaving space at bottom for tabs _containerView.Height = Dim.Fill (tabHeight); _tabsBar.Height = tabHeight; _tabsBar.Y = Pos.Bottom (_containerView); } else { // Tabs are along the top if (Style.ShowBorder) { _containerView.Border!.Thickness = new Thickness (1, 0, 1, 1); } _tabsBar.Y = 0; int tabHeight = GetTabHeight (true); //move content down to make space for tabs _containerView.Y = Pos.Bottom (_tabsBar); // Fill client area leaving space at bottom for border _containerView.Height = Dim.Fill (); // The top tab should be 2 or 3 rows high and on the top _tabsBar.Height = tabHeight; // Should be able to just use 0 but switching between top/bottom tabs repeatedly breaks in ValidatePosDim if just using the absolute value 0 } SetNeedsLayout (); } /// protected override void OnViewportChanged (DrawEventArgs e) { _tabLocations = CalculateViewport (Viewport).ToArray (); base.OnViewportChanged (e); } /// Updates to ensure that is visible. public void EnsureSelectedTabIsVisible () { if (!IsInitialized || SelectedTab is null) { return; } // if current viewport does not include the selected tab if (!CalculateViewport (Viewport).Any (t => Equals (SelectedTab, t))) { // Set scroll offset so the first tab rendered is the TabScrollOffset = Math.Max (0, Tabs.IndexOf (SelectedTab)); } } /// Updates to be a valid index of . /// The value to validate. /// Changes will not be immediately visible in the display until you call . /// The valid for the given value. public int EnsureValidScrollOffsets (int value) { return Math.Max (Math.Min (value, Tabs.Count - 1), 0); } /// protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) { if (SelectedTab is { HasFocus: false } && !_containerView.CanFocus && focusedView == this) { SelectedTab?.SetFocus (); return; } base.OnHasFocusChanged (newHasFocus, previousFocusedView, focusedView); } /// /// Removes the given from . Caller is responsible for disposing the /// tab's hosted if appropriate. /// /// public void RemoveTab (Tab? tab) { if (tab is null || !_tabs.Contains (tab)) { return; } // what tab was selected before closing int idx = _tabs.IndexOf (tab); _tabs.Remove (tab); // if the currently selected tab is no longer a member of Tabs if (SelectedTab is null || !Tabs.Contains (SelectedTab)) { // select the tab closest to the one that disappeared int toSelect = Math.Max (idx - 1, 0); if (toSelect < Tabs.Count) { SelectedTab = Tabs.ElementAt (toSelect); } else { SelectedTab = Tabs.LastOrDefault (); } } EnsureSelectedTabIsVisible (); SetNeedsLayout (); } /// Event for when changes. public event EventHandler? SelectedTabChanged; /// /// Changes the by the given . Positive for right, negative for /// left. If no tab is currently selected then the first tab will become selected. /// /// public bool SwitchTabBy (int amount) { if (Tabs.Count == 0) { return false; } // if there is only one tab anyway or nothing is selected if (Tabs.Count == 1 || SelectedTab is null) { SelectedTab = Tabs.ElementAt (0); return SelectedTab is { }; } int currentIdx = Tabs.IndexOf (SelectedTab); // Currently selected tab has vanished! if (currentIdx == -1) { SelectedTab = Tabs.ElementAt (0); return true; } int newIdx = Math.Max (0, Math.Min (currentIdx + amount, Tabs.Count - 1)); if (newIdx == currentIdx) { return false; } SelectedTab = _tabs [newIdx]; EnsureSelectedTabIsVisible (); return true; } /// /// Event fired when a is clicked. Can be used to cancel navigation, show context menu (e.g. on /// right click) etc. /// public event EventHandler? TabClicked; /// Disposes the control and all . /// protected override void Dispose (bool disposing) { base.Dispose (disposing); // The selected tab will automatically be disposed but // any tabs not visible will need to be manually disposed foreach (Tab tab in Tabs) { if (!Equals (SelectedTab, tab)) { tab.View?.Dispose (); } } } /// Raises the event. protected virtual void OnSelectedTabChanged (Tab oldTab, Tab newTab) { SelectedTabChanged?.Invoke (this, new TabChangedEventArgs (oldTab, newTab)); } /// Returns which tabs to render at each x location. /// internal IEnumerable CalculateViewport (Rectangle bounds) { UnSetCurrentTabs (); var i = 1; View? prevTab = null; // Starting at the first or scrolled to tab foreach (Tab tab in Tabs.Skip (TabScrollOffset)) { if (prevTab is { }) { tab.X = Pos.Right (prevTab) - 1; } else { tab.X = 0; } tab.Y = 0; // while there is space for the tab int tabTextWidth = tab.DisplayText.EnumerateRunes ().Sum (c => c.GetColumns ()); // The maximum number of characters to use for the tab name as specified // by the user (MaxTabTextWidth). But not more than the width of the view // or we won't even be able to render a single tab! long maxWidth = Math.Max (0, Math.Min (bounds.Width - 3, MaxTabTextWidth)); tab.Width = 2; tab.Height = Style.ShowTopLine ? 3 : 2; // if tab view is width <= 3 don't render any tabs if (maxWidth == 0) { tab.Visible = true; tab.MouseClick += Tab_MouseClick!; tab.Border!.MouseClick += Tab_MouseClick!; yield return tab; break; } if (tabTextWidth > maxWidth) { tab.Text = tab.DisplayText.Substring (0, (int)maxWidth); tabTextWidth = (int)maxWidth; } else { tab.Text = tab.DisplayText; } tab.Width = Math.Max (tabTextWidth + 2, 1); tab.Height = Style.ShowTopLine ? 3 : 2; // if there is not enough space for this tab if (i + tabTextWidth >= bounds.Width) { tab.Visible = false; break; } // there is enough space! tab.Visible = true; tab.MouseClick += Tab_MouseClick!; tab.Border!.MouseClick += Tab_MouseClick!; yield return tab; prevTab = tab; i += tabTextWidth + 1; } if (TabCanSetFocus ()) { SelectedTab?.SetFocus (); } } /// /// Returns the number of rows occupied by rendering the tabs, this depends on /// and can be 0 (e.g. if and you ask for ). /// /// True to measure the space required at the top of the control, false to measure space at the bottom. /// . /// private int GetTabHeight (bool top) { if (top && Style.TabsOnBottom) { return 0; } if (!top && !Style.TabsOnBottom) { return 0; } return Style.ShowTopLine ? 3 : 2; } internal void Tab_MouseClick (object sender, MouseEventArgs e) { e.Handled = _tabsBar.NewMouseEvent (e) == true; } private void UnSetCurrentTabs () { if (_tabLocations is null) { // Ensures unset any visible tab prior to TabScrollOffset for (int i = 0; i < TabScrollOffset; i++) { Tab tab = Tabs.ElementAt (i); if (tab.Visible) { tab.MouseClick -= Tab_MouseClick!; tab.Border!.MouseClick -= Tab_MouseClick!; tab.Visible = false; } } } else if (_tabLocations is { }) { foreach (Tab tabToRender in _tabLocations) { tabToRender.MouseClick -= Tab_MouseClick!; tabToRender.Border!.MouseClick -= Tab_MouseClick!; tabToRender.Visible = false; } _tabLocations = null; } } /// Raises the event. /// internal virtual void OnTabClicked (TabMouseEventArgs tabMouseEventArgs) { TabClicked?.Invoke (this, tabMouseEventArgs); } }