diff --git a/Terminal.Gui/Views/TabView.cs b/Terminal.Gui/Views/TabView.cs
new file mode 100644
index 000000000..a0e052f1d
--- /dev/null
+++ b/Terminal.Gui/Views/TabView.cs
@@ -0,0 +1,839 @@
+using NStack;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+
+namespace Terminal.Gui {
+
+ ///
+ /// A single tab in a
+ ///
+ public class Tab {
+ private ustring text;
+
+ ///
+ /// The text to display in a
+ ///
+ ///
+ public ustring Text { get => text ?? "Unamed"; set => text = value; }
+
+ ///
+ /// The control to display when the tab is selected
+ ///
+ ///
+ public View View { get; set; }
+
+ ///
+ /// Creates a new unamed tab with no controls inside
+ ///
+ public Tab ()
+ {
+
+ }
+
+ ///
+ /// Creates a new tab with the given text hosting a view
+ ///
+ ///
+ ///
+ public Tab (string text, View view)
+ {
+ this.Text = text;
+ this.View = view;
+ }
+ }
+
+ ///
+ /// Describes render stylistic selections of a
+ ///
+ public class TabStyle {
+
+ ///
+ /// True to show the top lip of tabs. False to directly begin with tab text during
+ /// rendering. When true header line occupies 3 rows, when false only 2.
+ /// Defaults to true.
+ ///
+ /// When is enabled this instead applies to the
+ /// bottommost line of the control
+ ///
+ public bool ShowTopLine { get; set; } = true;
+
+
+ ///
+ /// True to show a solid box around the edge of the control. Defaults to true.
+ ///
+ public bool ShowBorder { get; set; } = true;
+
+ ///
+ /// True to render tabs at the bottom of the view instead of the top
+ ///
+ public bool TabsOnBottom { get; set; } = false;
+
+ }
+
+ ///
+ /// Control that hosts multiple sub views, presenting a single one at once
+ ///
+ public class TabView : View {
+ private Tab selectedTab;
+
+ ///
+ /// The default to set on new controls
+ ///
+ public const uint DefaultMaxTabTextWidth = 30;
+
+ ///
+ /// This sub view is the 2 or 3 line control that represents the actual tabs themselves
+ ///
+ TabRowView tabsBar;
+
+ ///
+ /// This sub view is the main client area of the current tab. It hosts the
+ /// of the tab, the
+ ///
+ View contentView;
+ private List tabs = new List ();
+
+ ///
+ /// All tabs currently hosted by the control
+ ///
+ ///
+ public IReadOnlyCollection Tabs { get => tabs.AsReadOnly (); }
+
+ ///
+ /// When there are too many tabs to render, this indicates the first
+ /// tab to render on the screen.
+ ///
+ ///
+ public int TabScrollOffset { get; set; }
+
+ ///
+ /// 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;
+
+ ///
+ /// Event for when changes
+ ///
+ public event EventHandler SelectedTabChanged;
+
+ ///
+ /// The currently selected member of chosen by the user
+ ///
+ ///
+ public Tab SelectedTab {
+ get => selectedTab;
+ set {
+
+ var old = selectedTab;
+
+ if (selectedTab != null) {
+
+ if (selectedTab.View != null) {
+ // remove old content
+ contentView.Remove (selectedTab.View);
+ }
+ }
+
+ selectedTab = value;
+
+ if (value != null) {
+
+ // add new content
+ if (selectedTab.View != null) {
+ contentView.Add (selectedTab.View);
+ }
+ }
+
+ EnsureSelectedTabIsVisible ();
+
+ if (old != value) {
+ OnSelectedTabChanged (old, value);
+ }
+
+ }
+ }
+
+ ///
+ /// Render choices for how to display tabs. After making changes, call
+ ///
+ ///
+ public TabStyle Style { get; set; } = new TabStyle ();
+
+
+ ///
+ /// Initialzies a class using layout.
+ ///
+ public TabView () : base ()
+ {
+ CanFocus = true;
+ contentView = new View ();
+ tabsBar = new TabRowView (this);
+
+ ApplyStyleChanges ();
+
+ base.Add (tabsBar);
+ base.Add (contentView);
+ }
+
+ ///
+ /// 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 ()
+ {
+ contentView.X = Style.ShowBorder ? 1 : 0;
+ contentView.Width = Dim.Fill (Style.ShowBorder ? 1 : 0);
+
+ if (Style.TabsOnBottom) {
+ // Tabs are along the bottom so just dodge the border
+ contentView.Y = Style.ShowBorder ? 1 : 0;
+
+ // Fill client area leaving space at bottom for tabs
+ contentView.Height = Dim.Fill (GetTabHeight (false));
+
+ var tabHeight = GetTabHeight (false);
+ tabsBar.Height = tabHeight;
+
+ tabsBar.Y = Pos.Percent (100) - tabHeight;
+
+ } else {
+
+ // Tabs are along the top
+
+ var tabHeight = GetTabHeight (true);
+
+ //move content down to make space for tabs
+ contentView.Y = tabHeight;
+
+ // Fill client area leaving space at bottom for border
+ contentView.Height = Dim.Fill (Style.ShowBorder ? 1 : 0);
+
+ // 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
+ tabsBar.Y = Pos.Percent (0);
+ }
+
+
+ SetNeedsDisplay ();
+ }
+
+
+
+ ///
+ public override void Redraw (Rect bounds)
+ {
+ Move (0, 0);
+ Driver.SetAttribute (ColorScheme.Normal);
+
+ if (Style.ShowBorder) {
+
+ // How muc space do we need to leave at the bottom to show the tabs
+ int spaceAtBottom = Math.Max (0, GetTabHeight (false) - 1);
+ int startAtY = Math.Max (0, GetTabHeight (true) - 1);
+
+ DrawFrame (new Rect (0, startAtY, bounds.Width,
+ bounds.Height - spaceAtBottom - startAtY), 0, true);
+ }
+
+ if (Tabs.Any ()) {
+ tabsBar.Redraw (tabsBar.Bounds);
+ contentView.Redraw (contentView.Bounds);
+ }
+ }
+
+ ///
+ /// 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 (var 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));
+ }
+
+ ///
+ public override bool ProcessKey (KeyEvent keyEvent)
+ {
+ if (HasFocus && CanFocus && Focused == tabsBar) {
+ switch (keyEvent.Key) {
+
+ case Key.CursorLeft:
+ SwitchTabBy (-1);
+ return true;
+ case Key.CursorRight:
+ SwitchTabBy (1);
+ return true;
+ case Key.Home:
+ SelectedTab = Tabs.FirstOrDefault ();
+ return true;
+ case Key.End:
+ SelectedTab = Tabs.LastOrDefault ();
+ return true;
+ }
+ }
+
+ return base.ProcessKey (keyEvent);
+ }
+
+
+ ///
+ /// 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 void SwitchTabBy (int amount)
+ {
+ if (Tabs.Count == 0) {
+ return;
+ }
+
+ // if there is only one tab anyway or nothing is selected
+ if (Tabs.Count == 1 || SelectedTab == null) {
+ SelectedTab = Tabs.ElementAt (0);
+ SetNeedsDisplay ();
+ return;
+ }
+
+ var currentIdx = Tabs.IndexOf (SelectedTab);
+
+ // Currently selected tab has vanished!
+ if (currentIdx == -1) {
+ SelectedTab = Tabs.ElementAt (0);
+ SetNeedsDisplay ();
+ return;
+ }
+
+ var newIdx = Math.Max (0, Math.Min (currentIdx + amount, Tabs.Count - 1));
+
+ SelectedTab = tabs [newIdx];
+ SetNeedsDisplay ();
+
+ EnsureSelectedTabIsVisible ();
+ }
+
+
+ ///
+ /// Updates to be a valid index of
+ ///
+ /// Changes will not be immediately visible in the display until you call
+ public void EnsureValidScrollOffsets ()
+ {
+ TabScrollOffset = Math.Max (Math.Min (TabScrollOffset, Tabs.Count - 1), 0);
+ }
+
+ ///
+ /// Updates to ensure that is visible
+ ///
+ public void EnsureSelectedTabIsVisible ()
+ {
+ if (SelectedTab == null) {
+ return;
+ }
+
+ // if current viewport does not include the selected tab
+ if (!CalculateViewport (Bounds).Any (r => Equals (SelectedTab, r.Tab))) {
+
+ // Set scroll offset so the first tab rendered is the
+ TabScrollOffset = Math.Max (0, Tabs.IndexOf (SelectedTab));
+ }
+ }
+
+ ///
+ /// 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;
+ }
+
+
+ ///
+ /// Returns which tabs to render at each x location
+ ///
+ ///
+ private IEnumerable CalculateViewport (Rect bounds)
+ {
+ int i = 1;
+
+ // Starting at the first or scrolled to tab
+ foreach (var tab in Tabs.Skip (TabScrollOffset)) {
+
+ // while there is space for the tab
+ var tabTextWidth = tab.Text.Sum (c => Rune.ColumnWidth (c));
+
+ string text = tab.Text.ToString ();
+ var maxWidth = MaxTabTextWidth;
+
+ if (tabTextWidth > maxWidth) {
+ text = tab.Text.ToString ().Substring (0, (int)maxWidth);
+ tabTextWidth = (int)maxWidth;
+ }
+
+ // if there is not enough space for this tab
+ if (i + tabTextWidth >= bounds.Width) {
+ break;
+ }
+
+ // there is enough space!
+ yield return new TabToRender (i, tab, text, Equals (SelectedTab, tab), tabTextWidth);
+ i += tabTextWidth + 1;
+ }
+ }
+
+
+ ///
+ /// 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);
+
+ if (SelectedTab == null || andSelect) {
+ SelectedTab = tab;
+
+ EnsureSelectedTabIsVisible ();
+
+ tab.View?.SetFocus ();
+ }
+
+ SetNeedsDisplay ();
+ }
+
+
+ ///
+ /// Removes the given from .
+ /// Caller is responsible for disposing the tab's hosted
+ /// if appropriate.
+ ///
+ ///
+ public void RemoveTab (Tab tab)
+ {
+ if (tab == null || !tabs.Contains (tab)) {
+ return;
+ }
+
+ // what tab was selected before closing
+ var idx = tabs.IndexOf (tab);
+
+ tabs.Remove (tab);
+
+ // if the currently selected tab is no longer a member of Tabs
+ if (SelectedTab == null || !Tabs.Contains (SelectedTab)) {
+ // select the tab closest to the one that disapeared
+ var toSelect = Math.Max (idx - 1, 0);
+
+ if (toSelect < Tabs.Count) {
+ SelectedTab = Tabs.ElementAt (toSelect);
+ } else {
+ SelectedTab = Tabs.LastOrDefault ();
+ }
+
+ }
+
+ EnsureSelectedTabIsVisible ();
+ SetNeedsDisplay ();
+ }
+
+ private class TabToRender {
+ public int X { get; set; }
+ public Tab Tab { get; set; }
+
+ ///
+ /// True if the tab that is being rendered is the selected one
+ ///
+ ///
+ public bool IsSelected { get; set; }
+ public int Width { get; }
+ public string TextToRender { get; }
+
+ public TabToRender (int x, Tab tab, string textToRender, bool isSelected, int width)
+ {
+ X = x;
+ Tab = tab;
+ IsSelected = isSelected;
+ Width = width;
+ TextToRender = textToRender;
+ }
+ }
+
+ private class TabRowView : View {
+
+ readonly TabView host;
+
+ public TabRowView (TabView host)
+ {
+ this.host = host;
+
+ CanFocus = true;
+ Height = 1;
+ Width = Dim.Fill ();
+ }
+
+ ///
+ /// Positions the cursor at the start of the currently selected tab
+ ///
+ public override void PositionCursor ()
+ {
+ base.PositionCursor ();
+
+ var selected = host.CalculateViewport (Bounds).FirstOrDefault (t => Equals (host.SelectedTab, t.Tab));
+
+ if (selected == null) {
+ return;
+ }
+
+ int y;
+
+ if (host.Style.TabsOnBottom) {
+ y = 1;
+ } else {
+ y = host.Style.ShowTopLine ? 1 : 0;
+ }
+
+ Move (selected.X, y);
+
+
+
+ }
+
+ public override void Redraw (Rect bounds)
+ {
+ base.Redraw (bounds);
+
+ var tabLocations = host.CalculateViewport (bounds).ToArray ();
+ var width = bounds.Width;
+ Driver.SetAttribute (ColorScheme.Normal);
+
+ if (host.Style.ShowTopLine) {
+ RenderOverline (tabLocations, width);
+ }
+
+ RenderTabLine (tabLocations, width);
+
+ RenderUnderline (tabLocations, width);
+ Driver.SetAttribute (ColorScheme.Normal);
+
+
+ }
+
+ ///
+ /// Renders the line of the tabs that does not adjoin the content
+ ///
+ ///
+ ///
+ private void RenderOverline (TabToRender [] tabLocations, int width)
+ {
+ // if tabs are on the bottom draw the side of the tab that doesn't border the content area at the bottom otherwise the top
+ int y = host.Style.TabsOnBottom ? 2 : 0;
+
+ Move (0, y);
+
+ var selected = tabLocations.FirstOrDefault (t => t.IsSelected);
+
+ // Clear out everything
+ Driver.AddStr (new string (' ', width));
+
+ // Nothing is selected... odd but we are done
+ if (selected == null) {
+ return;
+ }
+
+
+ Move (selected.X - 1, y);
+ Driver.AddRune (host.Style.TabsOnBottom ? Driver.LLCorner : Driver.ULCorner);
+
+ for (int i = 0; i < selected.Width; i++) {
+
+ if (selected.X + i > width) {
+ // we ran out of space horizontally
+ return;
+ }
+
+ Driver.AddRune (Driver.HLine);
+ }
+
+ // Add the end of the selected tab
+ Driver.AddRune (host.Style.TabsOnBottom ? Driver.LRCorner : Driver.URCorner);
+
+ }
+
+ ///
+ /// Renders the line with the tab names in it
+ ///
+ ///
+ ///
+ private void RenderTabLine (TabToRender [] tabLocations, int width)
+ {
+ int y;
+
+ if (host.Style.TabsOnBottom) {
+
+ y = 1;
+ } else {
+ y = host.Style.ShowTopLine ? 1 : 0;
+ }
+
+
+
+ // clear any old text
+ Move (0, y);
+ Driver.AddStr (new string (' ', width));
+
+ foreach (var toRender in tabLocations) {
+
+ if (toRender.IsSelected) {
+ Move (toRender.X - 1, y);
+ Driver.AddRune (Driver.VLine);
+ }
+
+ Move (toRender.X, y);
+
+ // if tab is the selected one and focus is inside this control
+ if (toRender.IsSelected && host.HasFocus) {
+
+ if (host.Focused == this) {
+
+ // if focus is the tab bar ourself then show that they can switch tabs
+ Driver.SetAttribute (ColorScheme.HotFocus);
+
+ } else {
+
+ // Focus is inside the tab
+ Driver.SetAttribute (ColorScheme.HotNormal);
+ }
+ }
+
+
+ Driver.AddStr (toRender.TextToRender);
+ Driver.SetAttribute (ColorScheme.Normal);
+
+ if (toRender.IsSelected) {
+ Driver.AddRune (Driver.VLine);
+ }
+ }
+ }
+
+ ///
+ /// Renders the line of the tab that adjoins the content of the tab
+ ///
+ ///
+ ///
+ private void RenderUnderline (TabToRender [] tabLocations, int width)
+ {
+ int y = GetUnderlineYPosition ();
+
+ Move (0, y);
+
+ // If host has no border then we need to draw the solid line first (then we draw gaps over the top)
+ if (!host.Style.ShowBorder) {
+
+ for (int x = 0; x < width; x++) {
+ Driver.AddRune (Driver.HLine);
+ }
+
+
+ }
+ var selected = tabLocations.FirstOrDefault (t => t.IsSelected);
+
+ if (selected == null) {
+ return;
+ }
+
+ Move (selected.X - 1, y);
+
+ Driver.AddRune (selected.X == 1 ? Driver.VLine :
+ (host.Style.TabsOnBottom ? Driver.URCorner : Driver.LRCorner));
+
+ Driver.AddStr (new string (' ', selected.Width));
+
+
+ Driver.AddRune (selected.X + selected.Width == width - 1 ?
+ Driver.VLine :
+ (host.Style.TabsOnBottom ? Driver.ULCorner : Driver.LLCorner));
+
+
+ // draw scroll indicators
+
+ // if there are more tabs to the left not visible
+ if (host.TabScrollOffset > 0) {
+ Move (0, y);
+
+ // indicate that
+ Driver.AddRune (Driver.LeftArrow);
+ }
+
+ // if there are mmore tabs to the right not visible
+ if (ShouldDrawRightScrollIndicator (tabLocations)) {
+ Move (width - 1, y);
+
+ // indicate that
+ Driver.AddRune (Driver.RightArrow);
+ }
+ }
+
+ private bool ShouldDrawRightScrollIndicator (TabToRender [] tabLocations)
+ {
+ return tabLocations.LastOrDefault ()?.Tab != host.Tabs.LastOrDefault ();
+ }
+
+ private int GetUnderlineYPosition ()
+ {
+ if (host.Style.TabsOnBottom) {
+
+ return 0;
+ } else {
+
+ return host.Style.ShowTopLine ? 2 : 1;
+ }
+ }
+
+ public override bool MouseEvent (MouseEvent me)
+ {
+ if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) &&
+ !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) &&
+ !me.Flags.HasFlag (MouseFlags.Button1TripleClicked))
+ return false;
+
+ if (!HasFocus && CanFocus) {
+ SetFocus ();
+ }
+
+
+ if (me.Flags.HasFlag (MouseFlags.Button1Clicked) ||
+ me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) ||
+ me.Flags.HasFlag (MouseFlags.Button1TripleClicked)) {
+
+ var scrollIndicatorHit = ScreenToScrollIndicator (me.X, me.Y);
+
+ if (scrollIndicatorHit != 0) {
+
+ host.SwitchTabBy (scrollIndicatorHit);
+
+ SetNeedsDisplay ();
+ return true;
+ }
+
+ var hit = ScreenToTab (me.X, me.Y);
+ if (hit != null) {
+ host.SelectedTab = hit;
+ SetNeedsDisplay ();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Calculates whether scroll indicators are visible and if so whether the click
+ /// was on one of them.
+ ///
+ ///
+ ///
+ /// -1 for click in scroll left, 1 for scroll right or 0 for no hit
+ private int ScreenToScrollIndicator (int x, int y)
+ {
+ // scroll indicator is showing
+ if (host.TabScrollOffset > 0 && x == 0) {
+
+ return y == GetUnderlineYPosition () ? -1 : 0;
+ }
+
+ // scroll indicator is showing
+ if (x == Bounds.Width - 1 && ShouldDrawRightScrollIndicator (host.CalculateViewport (Bounds).ToArray ())) {
+
+ return y == GetUnderlineYPosition () ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Translates the client coordinates of a click into a tab when the click is on top of a tab
+ ///
+ ///
+ ///
+ ///
+ public Tab ScreenToTab (int x, int y)
+ {
+ var tabs = host.CalculateViewport (Bounds);
+
+ return tabs.LastOrDefault (t => x >= t.X && x < t.X + t.Width)?.Tab;
+ }
+ }
+ }
+
+ ///
+ /// Describes a change in
+ ///
+ public class TabChangedEventArgs : EventArgs {
+
+ ///
+ /// The previously selected tab. May be null
+ ///
+ public Tab OldTab { get; }
+
+ ///
+ /// The currently selected tab. May be null
+ ///
+ public Tab NewTab { get; }
+
+ ///
+ /// Documents a tab change
+ ///
+ ///
+ ///
+ public TabChangedEventArgs (Tab oldTab, Tab newTab)
+ {
+ OldTab = oldTab;
+ NewTab = newTab;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UICatalog/Scenarios/Notepad.cs b/UICatalog/Scenarios/Notepad.cs
new file mode 100644
index 000000000..dbf185dcb
--- /dev/null
+++ b/UICatalog/Scenarios/Notepad.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Terminal.Gui;
+using static UICatalog.Scenario;
+
+namespace UICatalog.Scenarios {
+
+ [ScenarioMetadata (Name: "Notepad", Description: "Multi tab text editor")]
+ [ScenarioCategory ("Controls")]
+ class Notepad : Scenario {
+
+ TabView tabView;
+ Label lblStatus;
+
+ private int numbeOfNewTabs = 1;
+
+ public override void Setup ()
+ {
+ Win.Title = this.GetName ();
+ Win.Y = 1; // menu
+ Win.Height = Dim.Fill (1); // status bar
+ Top.LayoutSubviews ();
+
+ var menu = new MenuBar (new MenuBarItem [] {
+ new MenuBarItem ("_File", new MenuItem [] {
+ new MenuItem ("_New", "", () => New()),
+ new MenuItem ("_Open", "", () => Open()),
+ new MenuItem ("_Save", "", () => Save()),
+ new MenuItem ("_Save As", "", () => SaveAs()),
+ new MenuItem ("_Close", "", () => Close()),
+ new MenuItem ("_Quit", "", () => Quit()),
+ })
+ });
+ Top.Add (menu);
+
+ tabView = new TabView () {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (1),
+ };
+
+ tabView.Style.ShowBorder = false;
+ tabView.ApplyStyleChanges ();
+
+ Win.Add (tabView);
+
+ var statusBar = new StatusBar (new StatusItem [] {
+ new StatusItem(Key.CtrlMask | Key.Q, "~^Q~ Quit", () => Quit()),
+
+ // These shortcut keys don't seem to work correctly in linux
+ //new StatusItem(Key.CtrlMask | Key.N, "~^O~ Open", () => Open()),
+ //new StatusItem(Key.CtrlMask | Key.N, "~^N~ New", () => New()),
+
+ new StatusItem(Key.CtrlMask | Key.S, "~^S~ Save", () => Save()),
+ new StatusItem(Key.CtrlMask | Key.W, "~^W~ Close", () => Close()),
+ });
+
+ Win.Add (lblStatus = new Label ("Len:") {
+ Y = Pos.Bottom (tabView),
+ Width = Dim.Fill (),
+ TextAlignment = TextAlignment.Right
+ });
+
+ tabView.SelectedTabChanged += (s, e) => UpdateStatus (e.NewTab);
+
+ Top.Add (statusBar);
+
+ New ();
+ }
+
+ private void UpdateStatus (Tab newTab)
+ {
+ lblStatus.Text = $"Len:{(newTab?.View?.Text?.Length ?? 0)}";
+ }
+
+ private void New ()
+ {
+ Open ("", null, $"new {numbeOfNewTabs++}");
+ }
+
+ private void Close ()
+ {
+ var tab = tabView.SelectedTab as OpenedFile;
+
+ if (tab == null) {
+ return;
+ }
+
+ if (tab.UnsavedChanges) {
+
+ int result = MessageBox.Query ("Save Changes", $"Save changes to {tab.Text.ToString ().TrimEnd ('*')}", "Yes", "No", "Cancel");
+
+ if (result == -1 || result == 2) {
+
+ // user cancelled
+ return;
+ }
+
+ if (result == 0) {
+ tab.Save ();
+ }
+ }
+
+ // close and dispose the tab
+ tabView.RemoveTab (tab);
+ tab.View.Dispose ();
+
+ }
+
+ private void Open ()
+ {
+
+ var open = new OpenDialog ("Open", "Open a file") { AllowsMultipleSelection = true };
+
+ Application.Run (open);
+
+ if (!open.Canceled) {
+
+ foreach (var path in open.FilePaths) {
+
+ if (string.IsNullOrEmpty (path) || !File.Exists (path)) {
+ return;
+ }
+
+ Open (File.ReadAllText (path), new FileInfo (path), Path.GetFileName (path));
+ }
+ }
+
+ }
+
+ ///
+ /// Creates a new tab with initial text
+ ///
+ ///
+ /// File that was read or null if a new blank document
+ private void Open (string initialText, FileInfo fileInfo, string tabName)
+ {
+
+ var textView = new TextView () {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Text = initialText
+ };
+
+ var tab = new OpenedFile (tabName, fileInfo, textView);
+ tabView.AddTab (tab, true);
+
+ // when user makes changes rename tab to indicate unsaved
+ textView.KeyUp += (k) => {
+
+ // if current text doesn't match saved text
+ var areDiff = tab.UnsavedChanges;
+
+ if (areDiff) {
+ if (!tab.Text.ToString ().EndsWith ('*')) {
+
+ tab.Text = tab.Text.ToString () + '*';
+ tabView.SetNeedsDisplay ();
+ }
+ } else {
+
+ if (tab.Text.ToString ().EndsWith ('*')) {
+
+ tab.Text = tab.Text.ToString ().TrimEnd ('*');
+ tabView.SetNeedsDisplay ();
+ }
+ }
+ };
+ }
+
+ public void Save ()
+ {
+ var tab = tabView.SelectedTab as OpenedFile;
+
+ if (tab == null) {
+ return;
+ }
+
+ if (tab.File == null) {
+ SaveAs ();
+ }
+
+ tab.Save ();
+
+ }
+
+ public bool SaveAs ()
+ {
+ var tab = tabView.SelectedTab as OpenedFile;
+
+ if (tab == null) {
+ return false;
+ }
+
+ var fd = new SaveDialog ();
+ Application.Run (fd);
+
+ if (string.IsNullOrWhiteSpace (fd.FilePath?.ToString ())) {
+ return false;
+ }
+
+ tab.File = new FileInfo (fd.FilePath.ToString ());
+ tab.Save ();
+
+ return true;
+ }
+
+ private class OpenedFile : Tab {
+
+
+ public FileInfo File { get; set; }
+
+ ///
+ /// The text of the tab the last time it was saved
+ ///
+ ///
+ public string SavedText { get; set; }
+
+ public bool UnsavedChanges => !string.Equals (SavedText, View.Text.ToString ());
+
+ public OpenedFile (string name, FileInfo file, TextView control) : base (name, control)
+ {
+ File = file;
+ SavedText = control.Text.ToString ();
+ }
+
+ internal void Save ()
+ {
+ var newText = View.Text.ToString ();
+
+ System.IO.File.WriteAllText (File.FullName, newText);
+ SavedText = newText;
+
+ Text = Text.ToString ().TrimEnd ('*');
+ }
+ }
+
+ private void Quit ()
+ {
+ Application.RequestStop ();
+ }
+ }
+}
diff --git a/UICatalog/Scenarios/TabViewExample.cs b/UICatalog/Scenarios/TabViewExample.cs
new file mode 100644
index 000000000..a9058224a
--- /dev/null
+++ b/UICatalog/Scenarios/TabViewExample.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Terminal.Gui;
+using static UICatalog.Scenario;
+
+namespace UICatalog.Scenarios {
+
+ [ScenarioMetadata (Name: "Tab View", Description: "Demos TabView control with limited screen space in Absolute layout")]
+ [ScenarioCategory ("Controls")]
+ class TabViewExample : Scenario {
+
+ TabView tabView;
+
+ MenuItem miShowTopLine;
+ MenuItem miShowBorder;
+ MenuItem miTabsOnBottom;
+
+ public override void Setup ()
+ {
+ Win.Title = this.GetName ();
+ Win.Y = 1; // menu
+ Win.Height = Dim.Fill (1); // status bar
+ Top.LayoutSubviews ();
+
+ var menu = new MenuBar (new MenuBarItem [] {
+ new MenuBarItem ("_File", new MenuItem [] {
+
+ new MenuItem ("_Add Blank Tab", "", () => AddBlankTab()),
+
+ new MenuItem ("_Clear SelectedTab", "", () => tabView.SelectedTab=null),
+ new MenuItem ("_Quit", "", () => Quit()),
+ }),
+ new MenuBarItem ("_View", new MenuItem [] {
+ miShowTopLine = new MenuItem ("_Show Top Line", "", () => ShowTopLine()){
+ Checked = true,
+ CheckType = MenuItemCheckStyle.Checked
+ },
+ miShowBorder = new MenuItem ("_Show Border", "", () => ShowBorder()){
+ Checked = true,
+ CheckType = MenuItemCheckStyle.Checked
+ },
+ miTabsOnBottom = new MenuItem ("_Tabs On Bottom", "", () => SetTabsOnBottom()){
+ Checked = false,
+ CheckType = MenuItemCheckStyle.Checked
+ }
+
+ })
+ });
+ Top.Add (menu);
+
+ tabView = new TabView () {
+ X = 0,
+ Y = 0,
+ Width = 60,
+ Height = 20,
+ };
+
+
+ tabView.AddTab (new Tab ("Tab1", new Label ("hodor!")), false);
+ tabView.AddTab (new Tab ("Tab2", new Label ("durdur")), false);
+ tabView.AddTab (new Tab ("Interactive Tab", GetInteractiveTab ()), false);
+ tabView.AddTab (new Tab ("Big Text", GetBigTextFileTab ()), false);
+ tabView.AddTab (new Tab (
+ "Long name Tab, I mean seriously long. Like you would not believe how long this tab's name is its just too much really woooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooowwww thats long",
+ new Label ("This tab has a very long name which should be truncated. See TabView.MaxTabTextWidth")),
+ false);
+ tabView.AddTab (new Tab ("Les Mise" + Char.ConvertFromUtf32 (Int32.Parse ("0301", NumberStyles.HexNumber)) + "rables", new Label ("This tab name is unicode")), false);
+
+ for (int i = 0; i < 100; i++) {
+ tabView.AddTab (new Tab ($"Tab{i}", new Label ($"Welcome to tab {i}")), false);
+ }
+
+ tabView.SelectedTab = tabView.Tabs.First ();
+
+ Win.Add (tabView);
+
+ var frameRight = new FrameView ("About") {
+ X = Pos.Right (tabView),
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ };
+
+
+ frameRight.Add (new TextView () {
+ Text = "This demos the tabs control\nSwitch between tabs using cursor keys",
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+ });
+
+ Win.Add (frameRight);
+
+
+
+ var frameBelow = new FrameView ("Bottom Frame") {
+ X = 0,
+ Y = Pos.Bottom (tabView),
+ Width = tabView.Width,
+ Height = Dim.Fill (),
+ };
+
+
+ frameBelow.Add (new TextView () {
+ Text = "This frame exists to check you can still tab here\nand that the tab control doesn't overspill it's bounds",
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+ });
+
+ Win.Add (frameBelow);
+
+ var statusBar = new StatusBar (new StatusItem [] {
+ new StatusItem(Key.CtrlMask | Key.Q, "~^Q~ Quit", () => Quit()),
+ });
+ Top.Add (statusBar);
+ }
+
+ private void AddBlankTab ()
+ {
+ tabView.AddTab (new Tab (), false);
+ }
+
+ private View GetInteractiveTab ()
+ {
+
+ var interactiveTab = new View () {
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+ };
+ var lblName = new Label ("Name:");
+ interactiveTab.Add (lblName);
+
+ var tbName = new TextField () {
+ X = Pos.Right (lblName),
+ Width = 10
+ };
+ interactiveTab.Add (tbName);
+
+ var lblAddr = new Label ("Address:") {
+ Y = 1
+ };
+ interactiveTab.Add (lblAddr);
+
+ var tbAddr = new TextField () {
+ X = Pos.Right (lblAddr),
+ Y = 1,
+ Width = 10
+ };
+ interactiveTab.Add (tbAddr);
+
+ return interactiveTab;
+ }
+
+
+ private View GetBigTextFileTab ()
+ {
+
+ var text = new TextView () {
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+ };
+
+ var sb = new System.Text.StringBuilder ();
+
+ for (int y = 0; y < 300; y++) {
+ for (int x = 0; x < 500; x++) {
+ sb.Append ((x + y) % 2 == 0 ? '1' : '0');
+ }
+ sb.AppendLine ();
+ }
+ text.Text = sb.ToString ();
+
+ return text;
+ }
+
+ private void ShowTopLine ()
+ {
+ miShowTopLine.Checked = !miShowTopLine.Checked;
+
+ tabView.Style.ShowTopLine = miShowTopLine.Checked;
+ tabView.ApplyStyleChanges ();
+ }
+ private void ShowBorder ()
+ {
+ miShowBorder.Checked = !miShowBorder.Checked;
+
+ tabView.Style.ShowBorder = miShowBorder.Checked;
+ tabView.ApplyStyleChanges ();
+ }
+ private void SetTabsOnBottom ()
+ {
+ miTabsOnBottom.Checked = !miTabsOnBottom.Checked;
+
+ tabView.Style.TabsOnBottom = miTabsOnBottom.Checked;
+ tabView.ApplyStyleChanges ();
+ }
+
+ private void Quit ()
+ {
+ Application.RequestStop ();
+ }
+ }
+}
diff --git a/UnitTests/TabViewTests.cs b/UnitTests/TabViewTests.cs
new file mode 100644
index 000000000..45003583a
--- /dev/null
+++ b/UnitTests/TabViewTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Threading.Tasks;
+using Terminal.Gui;
+using Xunit;
+using System.Globalization;
+
+namespace UnitTests {
+ public class TabViewTests {
+ private TabView GetTabView ()
+ {
+ return GetTabView (out _, out _);
+ }
+
+ private TabView GetTabView (out Tab tab1, out Tab tab2)
+ {
+ InitFakeDriver ();
+
+ var tv = new TabView ();
+ tv.AddTab (tab1 = new Tab ("Tab1", new TextField ("hi")), false);
+ tv.AddTab (tab2 = new Tab ("Tab2", new Label ("hi2")), false);
+ return tv;
+ }
+
+ [Fact]
+ public void AddTwoTabs_SecondIsSelected ()
+ {
+ InitFakeDriver ();
+
+ var tv = new TabView ();
+ Tab tab1;
+ Tab tab2;
+ tv.AddTab (tab1 = new Tab ("Tab1", new TextField ("hi")), false);
+ tv.AddTab (tab2 = new Tab ("Tab1", new Label ("hi2")), true);
+
+ Assert.Equal (2, tv.Tabs.Count);
+ Assert.Equal (tab2, tv.SelectedTab);
+ }
+
+
+ [Fact]
+ public void EnsureSelectedTabVisible_NullSelect ()
+ {
+ var tv = GetTabView ();
+
+ tv.SelectedTab = null;
+
+ Assert.Null (tv.SelectedTab);
+ Assert.Equal (0, tv.TabScrollOffset);
+
+ tv.EnsureSelectedTabIsVisible ();
+
+ Assert.Null (tv.SelectedTab);
+ Assert.Equal (0, tv.TabScrollOffset);
+ }
+
+ [Fact]
+ public void EnsureSelectedTabVisible_MustScroll ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ // Make tab width small to force only one tab visible at once
+ tv.Width = 4;
+
+ tv.SelectedTab = tab1;
+ Assert.Equal (0, tv.TabScrollOffset);
+ tv.EnsureSelectedTabIsVisible ();
+ Assert.Equal (0, tv.TabScrollOffset);
+
+ // Asking to show tab2 should automatically move scroll offset accordingly
+ tv.SelectedTab = tab2;
+ Assert.Equal (1, tv.TabScrollOffset);
+ }
+
+
+ [Fact]
+ public void SelectedTabChanged_Called ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ tv.SelectedTab = tab1;
+
+ Tab oldTab = null;
+ Tab newTab = null;
+ int called = 0;
+
+ tv.SelectedTabChanged += (s, e) => {
+ oldTab = e.OldTab;
+ newTab = e.NewTab;
+ called++;
+ };
+
+ tv.SelectedTab = tab2;
+
+ Assert.Equal (1, called);
+ Assert.Equal (tab1, oldTab);
+ Assert.Equal (tab2, newTab);
+ }
+ [Fact]
+ public void RemoveTab_ChangesSelection ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ tv.SelectedTab = tab1;
+ tv.RemoveTab (tab1);
+
+ Assert.Equal (tab2, tv.SelectedTab);
+ }
+
+ [Fact]
+ public void RemoveTab_MultipleCalls_NotAnError ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ tv.SelectedTab = tab1;
+
+ // Repeated calls to remove a tab that is not part of
+ // the collection should be ignored
+ tv.RemoveTab (tab1);
+ tv.RemoveTab (tab1);
+ tv.RemoveTab (tab1);
+ tv.RemoveTab (tab1);
+
+ Assert.Equal (tab2, tv.SelectedTab);
+ }
+
+ [Fact]
+ public void RemoveAllTabs_ClearsSelection ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ tv.SelectedTab = tab1;
+ tv.RemoveTab (tab1);
+ tv.RemoveTab (tab2);
+
+ Assert.Null (tv.SelectedTab);
+ }
+
+ [Fact]
+ public void SwitchTabBy_NormalUsage ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ Tab tab3;
+ Tab tab4;
+ Tab tab5;
+
+ tv.AddTab (tab3 = new Tab (), false);
+ tv.AddTab (tab4 = new Tab (), false);
+ tv.AddTab (tab5 = new Tab (), false);
+
+ tv.SelectedTab = tab1;
+
+ int called = 0;
+ tv.SelectedTabChanged += (s, e) => { called++; };
+
+ tv.SwitchTabBy (1);
+
+ Assert.Equal (1, called);
+ Assert.Equal (tab2, tv.SelectedTab);
+
+ //reset called counter
+ called = 0;
+
+ // go right 2
+ tv.SwitchTabBy (2);
+
+ // even though we go right 2 indexes the event should only be called once
+ Assert.Equal (1, called);
+ Assert.Equal (tab4, tv.SelectedTab);
+ }
+
+ [Fact]
+ public void AddTab_SameTabMoreThanOnce ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ Assert.Equal (2, tv.Tabs.Count);
+
+ // Tab is already part of the control so shouldn't result in duplication
+ tv.AddTab (tab1, false);
+ tv.AddTab (tab1, false);
+ tv.AddTab (tab1, false);
+ tv.AddTab (tab1, false);
+
+ Assert.Equal (2, tv.Tabs.Count);
+ }
+
+
+
+ [Fact]
+ public void SwitchTabBy_OutOfTabsRange ()
+ {
+ var tv = GetTabView (out var tab1, out var tab2);
+
+ tv.SelectedTab = tab1;
+ tv.SwitchTabBy (500);
+
+ Assert.Equal (tab2, tv.SelectedTab);
+
+ tv.SwitchTabBy (-500);
+
+ Assert.Equal (tab1, tv.SelectedTab);
+
+ }
+
+ private void InitFakeDriver ()
+ {
+ var driver = new FakeDriver ();
+ Application.Init (driver, new FakeMainLoop (() => FakeConsole.ReadKey (true)));
+ driver.Init (() => { });
+ }
+ }
+}
\ No newline at end of file