#nullable enable using System.Linq; using static Terminal.Gui.SpinnerStyle; using static Unix.Terminal.Delegates; namespace Terminal.Gui; /// Control that hosts multiple sub views, presenting a single one at once. public class TabView : View, IDesignable { /// The default to set on new controls. public const uint DefaultMaxTabTextWidth = 30; /// This SubView is the 2 or 3 line control that represents the actual tabs themselves. private readonly TabRowView _tabRowView; // private TabToRender []? _tabLocations; /// Initializes a class. public TabView () { CanFocus = true; TabStop = TabBehavior.TabStop; // Because TabView has focusable subviews, it must be a TabGroup Width = Dim.Fill (); Height = Dim.Auto (minimumContentDim: GetTabHeight (!Style.TabsOnBottom)); _tabRowView = new TabRowView (); _tabRowView.Selecting += _tabRowView_Selecting; base.Add (_tabRowView); ApplyStyleChanges (); // Things this view knows how to do AddCommand (Command.Left, () => SwitchTabBy (-1)); AddCommand (Command.Right, () => SwitchTabBy (1)); AddCommand ( Command.LeftStart, () => { FirstVisibleTabIndex = 0; SelectedTabIndex = 0; return true; } ); AddCommand ( Command.RightEnd, () => { FirstVisibleTabIndex = Tabs.Count - 1; SelectedTabIndex = Tabs.Count - 1; return true; } ); AddCommand ( Command.PageDown, () => { // FirstVisibleTabIndex += _tabLocations!.Length; SelectedTabIndex = FirstVisibleTabIndex; return true; } ); AddCommand ( Command.PageUp, () => { // FirstVisibleTabIndex -= _tabLocations!.Length; SelectedTabIndex = FirstVisibleTabIndex; return true; } ); AddCommand (Command.ScrollLeft, () => { var visibleTabs = GetTabsThatCanBeVisible (Viewport).ToArray (); int? first = visibleTabs.FirstOrDefault (); if (first > 0) { int scroll = -_tabRowView.Tabs.ToArray () [first.Value].Frame.Width; _tabRowView.Viewport = _tabRowView.Viewport with { X = _tabRowView.Viewport.X + scroll }; SetNeedsLayout (); FirstVisibleTabIndex--; return true; } return false; }); AddCommand (Command.ScrollRight, () => { var visibleTabs = GetTabsThatCanBeVisible (Viewport).ToArray (); int? last = visibleTabs.LastOrDefault (); if (last is { }) { _tabRowView.ScrollHorizontal (_tabRowView.Tabs.ToArray () [last.Value + 1].Frame.Width); SetNeedsLayout (); FirstVisibleTabIndex++; return true; } return false; }); //// Space or single-click - Raise Selecting //AddCommand (Command.Select, (ctx) => // { // //if (RaiseSelecting (ctx) is true) // //{ // // return true; // //} // if (ctx.Data is Tab tab) // { // int? current = SelectedTabIndex; // SelectedTabIndex = _tabRowView.Tabs.ToArray ().IndexOf (tab); // SetNeedsDraw (); // // e.Cancel = HasFocus; // return true; // } // return false; // }); // 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); } private void _tabRowView_Selecting (object? sender, CommandEventArgs e) { if (e.Context.Data is int tabIndex) { int? current = SelectedTabIndex; SelectedTabIndex = tabIndex; Layout (); e.Cancel = true; } } /// protected override void OnSubviewLayout (LayoutEventArgs args) { _tabRowView.CalcContentSize (); } /// protected override void OnSubviewsLaidOut (LayoutEventArgs args) { // hide all that can't fit var visibleTabs = GetTabsThatCanBeVisible (Viewport).ToArray (); for (var index = 0; index < _tabRowView.Tabs.ToArray ().Length; index++) { Tab tab = _tabRowView.Tabs.ToArray () [index]; tab.Visible = visibleTabs.Contains (index); } } /// public bool EnableForDesign () { AddTab (new () { Text = "Tab_1", Id = "tab1", View = new Label { Text = "Label in Tab1" } }, false); AddTab (new () { Text = "Tab _2", Id = "tab2", View = new TextField { Text = "TextField in Tab2", Width = 10 } }, false); AddTab (new () { Text = "Tab _Three", Id = "tab3", View = new Label { Text = "Label in Tab3" } }, false); AddTab (new () { Text = "Tab _Quattro", Id = "tab4", View = new TextField { Text = "TextField in Tab4", Width = 10 } }, false); return true; } /// /// 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; private int? _selectedTabIndex; /// The currently selected member of chosen by the user. /// public int? SelectedTabIndex { get => _selectedTabIndex; set { // If value is outside the range of Tabs, throw an exception if (value < 0 || value >= Tabs.Count) { throw new ArgumentOutOfRangeException (nameof (value), value, @"SelectedTab the range of Tabs."); } if (value == _selectedTabIndex) { return; } int? old = _selectedTabIndex; // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); if (_selectedTabIndex is { } && tabs [_selectedTabIndex.Value].View is { }) { Remove (tabs [_selectedTabIndex.Value].View); } _selectedTabIndex = value; if (_selectedTabIndex is { } && tabs [_selectedTabIndex.Value].View is { }) { Add (tabs [_selectedTabIndex.Value].View); } EnsureSelectedTabIsVisible (); if (_selectedTabIndex is { }) { ApplyStyleChanges (); if (HasFocus) { tabs [_selectedTabIndex.Value].View.SetFocus (); } } OnSelectedTabIndexChanged (old, _selectedTabIndex!); SelectedTabChanged?.Invoke (this, new TabChangedEventArgs (old, _selectedTabIndex)); SetNeedsLayout (); } } 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 => _tabRowView.Tabs.ToArray ().AsReadOnly (); private int _firstVisibleTabIndex; /// Gets or sets the index of first visible tab. This enables horizontal scrolling of the tabs. /// /// /// On set, if the value is less than 0, it will be set to 0. If the value is greater than the number of tabs /// it will be set to the last tab index. /// /// public int FirstVisibleTabIndex { get => _firstVisibleTabIndex; set { _firstVisibleTabIndex = Math.Max (Math.Min (value, Tabs.Count - 1), 0); ; SetNeedsLayout (); } } /// Adds the given to . /// /// True to make the newly added Tab the . public void AddTab (Tab tab, bool andSelect) { // Ok to use Subviews here instead of Tabs if (_tabRowView.Subviews.Contains (tab)) { return; } // Add to the TabRowView as a subview _tabRowView.Add (tab); if (_tabRowView.Tabs.Count () == 1 || andSelect) { SelectedTabIndex = _tabRowView.Tabs.Count () - 1; EnsureSelectedTabIsVisible (); if (HasFocus) { tab.View?.SetFocus (); } } ApplyStyleChanges (); SetNeedsLayout (); } /// /// Removes the given from . Caller is responsible for disposing the /// tab's hosted if appropriate. /// /// public void RemoveTab (Tab? tab) { if (tab is null || !_tabRowView.Subviews.Contains (tab)) { return; } int idx = _tabRowView.Tabs.ToArray ().IndexOf (tab); if (idx == SelectedTabIndex) { SelectedTabIndex = null; } _tabRowView.Remove (tab); // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); if (SelectedTabIndex is null) { // Either no tab was previously selected or the selected tab was removed // select the tab closest to the one that disappeared int toSelect = Math.Max (idx - 1, 0); if (toSelect < tabs.Length) { SelectedTabIndex = toSelect; } else { SelectedTabIndex = tabs.Length - 1; } } if (SelectedTabIndex > tabs.Length - 1) { // Removing the tab, caused the selected tab to be out of range SelectedTabIndex = tabs.Length - 1; } EnsureSelectedTabIsVisible (); SetNeedsLayout (); } /// /// Applies the settings in . This can change the dimensions of /// (for rendering the selected tab's content). This method includes a call to /// . /// public void ApplyStyleChanges () { // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); View? selectedView = null; if (SelectedTabIndex is { }) { selectedView = tabs [SelectedTabIndex.Value].View; } if (selectedView is { }) { selectedView.BorderStyle = Style.ShowBorder ? LineStyle.Single : LineStyle.None; selectedView.Width = Dim.Fill (); } int tabHeight = GetTabHeight (!Style.TabsOnBottom); if (Style.TabsOnBottom) { _tabRowView.Height = tabHeight; _tabRowView.Y = Pos.AnchorEnd (); if (selectedView is { }) { // Tabs are along the bottom so just dodge the border if (Style.ShowBorder && selectedView?.Border is { }) { selectedView.Border.Thickness = new Thickness (1, 1, 1, 0); } // Fill client area leaving space at bottom for tabs selectedView!.Y = 0; selectedView.Height = Dim.Fill (tabHeight); } } else { // Tabs are along the top _tabRowView.Height = tabHeight; _tabRowView.Y = 0; if (selectedView is { }) { if (Style.ShowBorder && selectedView.Border is { }) { selectedView.Border.Thickness = new Thickness (1, 0, 1, 1); } //move content down to make space for tabs selectedView.Y = Pos.Bottom (_tabRowView); // Fill client area leaving space at bottom for border selectedView.Height = Dim.Fill (); } } SetNeedsLayout (); } /// Updates to ensure that is visible. public void EnsureSelectedTabIsVisible () { if (SelectedTabIndex is null) { return; } // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); View? selectedView = tabs [SelectedTabIndex.Value].View; if (selectedView is null) { return; } // if current viewport does not include the selected tab if (!GetTabsThatCanBeVisible (Viewport).Any (r => Equals (SelectedTabIndex.Value, r))) { // Set scroll offset so the first tab rendered is the FirstVisibleTabIndex = Math.Max (0, SelectedTabIndex.Value); } } /// 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. /// /// /// if a change was made. public bool SwitchTabBy (int amount) { // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); if (tabs.Length == 0) { return false; } int? currentIdx = SelectedTabIndex; // if there is only one tab anyway or nothing is selected if (tabs.Length == 1) { SelectedTabIndex = 0; return SelectedTabIndex != currentIdx; } // Currently selected tab has vanished! if (currentIdx is null) { SelectedTabIndex = 0; return true; } int newIdx = Math.Max (0, Math.Min (currentIdx.Value + amount, tabs.Length - 1)); if (newIdx == currentIdx) { return false; } SelectedTabIndex = newIdx; return true; } /// Called when the has changed. protected virtual void OnSelectedTabIndexChanged (int? oldTabIndex, int? newTabIndex) { } /// Returns which tabs will be visible given the dimensions of the TabView, which tab is selected, and how the tabs have been scrolled. /// Same as this.Frame. /// private IEnumerable GetTabsThatCanBeVisible (Rectangle bounds) { var curWidth = 1; View? prevTab = null; // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); // Starting at the first or scrolled to tab for (int i = FirstVisibleTabIndex; i < tabs.Length; i++) { if (curWidth >= bounds.Width) { break; } if (curWidth + tabs [i].Frame.Width < bounds.Width) { yield return i; } curWidth += tabs [i].Frame.Width; } } /// /// 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; } /// protected override void Dispose (bool disposing) { if (disposing) { // Get once to avoid multiple enumerations Tab [] tabs = _tabRowView.Tabs.ToArray (); if (SelectedTabIndex is { }) { Remove (tabs [SelectedTabIndex.Value].View); } foreach (Tab tab in tabs) { tab.View?.Dispose (); tab.View = null; } }; base.Dispose (disposing); } private class TabRowView : View { private readonly View _leftScrollIndicator; private readonly View _rightScrollIndicator; public TabRowView () { Id = "tabRowView"; CanFocus = true; Height = Dim.Auto (); Width = Dim.Fill (); SuperViewRendersLineCanvas = true; _rightScrollIndicator = new View { Id = "rightScrollIndicator", X = Pos.Func (() => Viewport.X + Viewport.Width - 1), Y = Pos.AnchorEnd (), Width = 1, Height = 1, Visible = true, Text = Glyphs.RightArrow.ToString () }; _leftScrollIndicator = new View { Id = "leftScrollIndicator", X = Pos.Func (() => Viewport.X), Y = Pos.AnchorEnd (), Width = 1, Height = 1, Visible = true, Text = Glyphs.LeftArrow.ToString () }; Add (_rightScrollIndicator, _leftScrollIndicator); Initialized += OnInitialized; } private void OnInitialized (object? sender, EventArgs e) { if (SuperView is TabView tabView) { _leftScrollIndicator.MouseClick += (o, args) => { tabView.InvokeCommand (Command.ScrollLeft); }; _rightScrollIndicator.MouseClick += (o, args) => { tabView.InvokeCommand (Command.ScrollRight); }; tabView.SelectedTabChanged += TabView_SelectedTabChanged; } CalcContentSize (); } private void TabView_SelectedTabChanged (object? sender, TabChangedEventArgs e) { _selectedTabIndex = e.NewTabIndex; CalcContentSize (); } /// public override void OnAdded (SuperViewChangedEventArgs e) { if (e.SubView is Tab tab) { MoveSubviewToEnd (_leftScrollIndicator); MoveSubviewToEnd (_rightScrollIndicator); tab.HasFocusChanged += TabOnHasFocusChanged; tab.Selecting += Tab_Selecting; } CalcContentSize (); } private void Tab_Selecting (object? sender, CommandEventArgs e) { e.Cancel = RaiseSelecting (new CommandContext (Command.Select, null, data: Tabs.ToArray ().IndexOf (sender))) is true; } private void TabOnHasFocusChanged (object? sender, HasFocusEventArgs e) { TabView? host = SuperView as TabView; if (host is null) { return; } //if (e is { NewFocused: Tab tab, NewValue: true }) //{ // e.Cancel = RaiseSInvokeCommand (Command.Select, new CommandContext () { Data = tab }) is true; //} } public void CalcContentSize () { TabView? host = SuperView as TabView; if (host is null) { return; } Tab? selected = null; int topLine = host!.Style.ShowTopLine ? 1 : 0; Tab [] tabs = Tabs.ToArray (); for (int i = 0; i < tabs.Length; i++) { tabs [i].Height = Dim.Fill (); if (i == 0) { tabs [i].X = 0; } else { tabs [i].X = Pos.Right (tabs [i - 1]); } if (i == _selectedTabIndex) { selected = tabs [i]; if (host.Style.TabsOnBottom) { tabs [i].Border.Thickness = new Thickness (1, 0, 1, topLine); tabs [i].Margin.Thickness = new Thickness (0, 1, 0, 0); } else { tabs [i].Border.Thickness = new Thickness (1, topLine, 1, 0); tabs [i].Margin.Thickness = new Thickness (0, 0, 0, topLine); } } else if (selected is null) { if (host.Style.TabsOnBottom) { tabs [i].Border.Thickness = new Thickness (1, 1, 0, topLine); tabs [i].Margin.Thickness = new Thickness (0, 0, 0, 0); } else { tabs [i].Border.Thickness = new Thickness (1, topLine, 0, 1); tabs [i].Margin.Thickness = new Thickness (0, 0, 0, 0); } //tabs [i].Width = Math.Max (tabs [i].Width!.GetAnchor (0) - 1, 1); } else { if (host.Style.TabsOnBottom) { tabs [i].Border.Thickness = new Thickness (0, 1, 1, topLine); tabs [i].Margin.Thickness = new Thickness (0, 0, 0, 0); } else { tabs [i].Border.Thickness = new Thickness (0, topLine, 1, 1); tabs [i].Margin.Thickness = new Thickness (0, 0, 0, 0); } //tabs [i].Width = Math.Max (tabs [i].Width!.GetAnchor (0) - 1, 1); } //tabs [i].Text = toRender.TextToRender; } SetContentSize (null); Layout (Application.Screen.Size); var width = 0; foreach (Tab t in tabs) { width += t.Frame.Width; } SetContentSize (new (width, Viewport.Height)); } internal IEnumerable Tabs => Subviews.Where (v => v is Tab).Cast (); private int? _selectedTabIndex = null; } }