소스 검색

New Control: Tabview (#1137)

* started working on tab view

* Ability to switch tabs

* Added interactive tab

* Added ShowBorder

* Fixed not being able to focus tabs

* Made tab row into private class and implemented PositionCursor

* Added support for TabsOnBottom

* Fixed layout flipping repeatedly between top and bottom tabs

* support for scrolling to infinite tabs

* Added scroll indicators

* Made Tabs readonly and added Notepad Scenario

* Fleshed out Notepad app

* Added SelectedTabChanged event

* Improved visiblity of where focus is and made example Absolute layout

* Added unicode tab to example

* Prototype mouse support

* Refactored tab rendering logic into sub view TabRowView

* Fixed bugs in Notepad scenario and xml doc

* Fixed position of cursor when TabsOnBottom and ShowHeaderOverline are both true

* Fixed PositionCursor when TabsOnBottom (properly this time)

* Fixed bugs when a Tab had a null View

* Fixed RemoveTab when SelectedTab is null and docs

* Fixed whitespace to match guidelines

* Fixed tabsBar position bug TabView.Y is not 0

* Added MaxTabTextWidth property

* Fixed issues based on feedback

* Support for clicking on scroll indicators

* Added tests for TabView

* Fixed horizontal line in empty tab view

* Fixed whitespace to match coding guidelines

* Added more tests, fixed AddTab allowing duplicates

* Fixed TabView not responding to double/triple click on arrows

* Refactored clicking scroll indicators to use SwitchTabBy

* Changed FileDialog to OpenDialog in Notepad Scenario
Includes support for opening multiple at once
Thomas Nind 4 년 전
부모
커밋
1f01ff86fd
4개의 변경된 파일1511개의 추가작업 그리고 0개의 파일을 삭제
  1. 839 0
      Terminal.Gui/Views/TabView.cs
  2. 250 0
      UICatalog/Scenarios/Notepad.cs
  3. 206 0
      UICatalog/Scenarios/TabViewExample.cs
  4. 216 0
      UnitTests/TabViewTests.cs

+ 839 - 0
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 {
+
+	/// <summary>
+	/// A single tab in a <see cref="TabView"/>
+	/// </summary>
+	public class Tab {
+		private ustring text;
+
+		/// <summary>
+		/// The text to display in a <see cref="TabView"/>
+		/// </summary>
+		/// <value></value>
+		public ustring Text { get => text ?? "Unamed"; set => text = value; }
+
+		/// <summary>
+		/// The control to display when the tab is selected
+		/// </summary>
+		/// <value></value>
+		public View View { get; set; }
+
+		/// <summary>
+		/// Creates a new unamed tab with no controls inside
+		/// </summary>
+		public Tab ()
+		{
+
+		}
+
+		/// <summary>
+		/// Creates a new tab with the given text hosting a view
+		/// </summary>
+		/// <param name="text"></param>
+		/// <param name="view"></param>
+		public Tab (string text, View view)
+		{
+			this.Text = text;
+			this.View = view;
+		}
+	}
+
+	/// <summary>
+	/// Describes render stylistic selections of a <see cref="TabView"/>
+	/// </summary>
+	public class TabStyle {
+
+		/// <summary>
+		/// 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.
+		/// 
+		/// <para>When <see cref="TabsOnBottom"/> is enabled this instead applies to the
+		///  bottommost line of the control</para>
+		/// </summary> 
+		public bool ShowTopLine { get; set; } = true;
+
+
+		/// <summary>
+		/// True to show a solid box around the edge of the control.  Defaults to true.
+		/// </summary>
+		public bool ShowBorder { get; set; } = true;
+
+		/// <summary>
+		/// True to render tabs at the bottom of the view instead of the top
+		/// </summary>
+		public bool TabsOnBottom { get; set; } = false;
+
+	}
+
+	/// <summary>
+	/// Control that hosts multiple sub views, presenting a single one at once
+	/// </summary>
+	public class TabView : View {
+		private Tab selectedTab;
+
+		/// <summary>
+		/// The default <see cref="MaxTabTextWidth"/> to set on new <see cref="TabView"/> controls
+		/// </summary>
+		public const uint DefaultMaxTabTextWidth = 30;
+
+		/// <summary>
+		/// This sub view is the 2 or 3 line control that represents the actual tabs themselves
+		/// </summary>
+		TabRowView tabsBar;
+
+		/// <summary>
+		/// This sub view is the main client area of the current tab.  It hosts the <see cref="Tab.View"/> 
+		/// of the tab, the <see cref="SelectedTab"/>
+		/// </summary>
+		View contentView;
+		private List<Tab> tabs = new List<Tab> ();
+
+		/// <summary>
+		/// All tabs currently hosted by the control
+		/// </summary>
+		/// <value></value>
+		public IReadOnlyCollection<Tab> Tabs { get => tabs.AsReadOnly (); }
+
+		/// <summary>
+		/// When there are too many tabs to render, this indicates the first
+		/// tab to render on the screen.
+		/// </summary>
+		/// <value></value>
+		public int TabScrollOffset { get; set; }
+
+		/// <summary>
+		/// The maximum number of characters to render in a Tab header.  This prevents one long tab 
+		/// from pushing out all the others.
+		/// </summary>
+		public uint MaxTabTextWidth { get; set; } = DefaultMaxTabTextWidth;
+
+		/// <summary>
+		/// Event for when <see cref="SelectedTab"/> changes
+		/// </summary>
+		public event EventHandler<TabChangedEventArgs> SelectedTabChanged;
+
+		/// <summary>
+		/// The currently selected member of <see cref="Tabs"/> chosen by the user
+		/// </summary>
+		/// <value></value>
+		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);
+				}
+
+			}
+		}
+
+		/// <summary>
+		/// Render choices for how to display tabs.  After making changes, call <see cref="ApplyStyleChanges()"/>
+		/// </summary>
+		/// <value></value>
+		public TabStyle Style { get; set; } = new TabStyle ();
+
+
+		/// <summary>
+		/// Initialzies a <see cref="TabView"/> class using <see cref="LayoutStyle.Computed"/> layout.
+		/// </summary>
+		public TabView () : base ()
+		{
+			CanFocus = true;
+			contentView = new View ();
+			tabsBar = new TabRowView (this);
+
+			ApplyStyleChanges ();
+
+			base.Add (tabsBar);
+			base.Add (contentView);
+		}
+
+		/// <summary>
+		/// Updates the control to use the latest state settings in <see cref="Style"/>.
+		/// 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 <see cref="View.SetNeedsDisplay()"/>
+		/// </summary>
+		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 ();
+		}
+
+
+
+		///<inheritdoc/>
+		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);
+			}
+		}
+
+		/// <summary>
+		/// Disposes the control and all <see cref="Tabs"/>
+		/// </summary>
+		/// <param name="disposing"></param>
+		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 ();
+				}
+
+			}
+		}
+
+		/// <summary>
+		/// Raises the <see cref="SelectedTabChanged"/> event
+		/// </summary>
+		protected virtual void OnSelectedTabChanged (Tab oldTab, Tab newTab)
+		{
+
+			SelectedTabChanged?.Invoke (this, new TabChangedEventArgs (oldTab, newTab));
+		}
+
+		/// <inheritdoc/>
+		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);
+		}
+
+
+		/// <summary>
+		/// Changes the <see cref="SelectedTab"/> by the given <paramref name="amount"/>.  
+		/// Positive for right, negative for left.  If no tab is currently selected then
+		/// the first tab will become selected
+		/// </summary>
+		/// <param name="amount"></param>
+		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 ();
+		}
+
+
+		/// <summary>
+		/// Updates <see cref="TabScrollOffset"/> to be a valid index of <see cref="Tabs"/>
+		/// </summary>
+		/// <remarks>Changes will not be immediately visible in the display until you call <see cref="View.SetNeedsDisplay()"/></remarks>
+		public void EnsureValidScrollOffsets ()
+		{
+			TabScrollOffset = Math.Max (Math.Min (TabScrollOffset, Tabs.Count - 1), 0);
+		}
+
+		/// <summary>
+		/// Updates <see cref="TabScrollOffset"/> to ensure that <see cref="SelectedTab"/> is visible
+		/// </summary>
+		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));
+			}
+		}
+
+		/// <summary>
+		/// Returns the number of rows occupied by rendering the tabs, this depends 
+		/// on <see cref="TabStyle.ShowTopLine"/> and can be 0 (e.g. if 
+		/// <see cref="TabStyle.TabsOnBottom"/> and you ask for <paramref name="top"/>).
+		/// </summary>
+		/// <param name="top">True to measure the space required at the top of the control,
+		/// false to measure space at the bottom</param>
+		/// <returns></returns>
+		private int GetTabHeight (bool top)
+		{
+			if (top && Style.TabsOnBottom) {
+				return 0;
+			}
+
+			if (!top && !Style.TabsOnBottom) {
+				return 0;
+			}
+
+			return Style.ShowTopLine ? 3 : 2;
+		}
+
+
+		/// <summary>
+		/// Returns which tabs to render at each x location
+		/// </summary>
+		/// <returns></returns>
+		private IEnumerable<TabToRender> 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;
+			}
+		}
+
+
+		/// <summary>
+		/// Adds the given <paramref name="tab"/> to <see cref="Tabs"/>
+		/// </summary>
+		/// <param name="tab"></param>
+		/// <param name="andSelect">True to make the newly added Tab the <see cref="SelectedTab"/></param>
+		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 ();
+		}
+
+
+		/// <summary>
+		/// Removes the given <paramref name="tab"/> from <see cref="Tabs"/>.
+		/// Caller is responsible for disposing the tab's hosted <see cref="Tab.View"/>
+		/// if appropriate.
+		/// </summary>
+		/// <param name="tab"></param>
+		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; }
+
+			/// <summary>
+			/// True if the tab that is being rendered is the selected one
+			/// </summary>
+			/// <value></value>
+			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 ();
+			}
+
+			/// <summary>
+			/// Positions the cursor at the start of the currently selected tab
+			/// </summary>
+			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);
+
+
+			}
+
+			/// <summary>
+			/// Renders the line of the tabs that does not adjoin the content
+			/// </summary>
+			/// <param name="tabLocations"></param>
+			/// <param name="width"></param>
+			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);
+
+			}
+
+			/// <summary>
+			/// Renders the line with the tab names in it
+			/// </summary>
+			/// <param name="tabLocations"></param>
+			/// <param name="width"></param>
+			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);
+					}
+				}
+			}
+
+			/// <summary>
+			/// Renders the line of the tab that adjoins the content of the tab
+			/// </summary>
+			/// <param name="tabLocations"></param>
+			/// <param name="width"></param>
+			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;
+			}
+
+			/// <summary>
+			/// Calculates whether scroll indicators are visible and if so whether the click
+			/// was on one of them.
+			/// </summary>
+			/// <param name="x"></param>
+			/// <param name="y"></param>
+			/// <returns>-1 for click in scroll left, 1 for scroll right or 0 for no hit</returns>
+			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;
+			}
+
+			/// <summary>
+			/// Translates the client coordinates of a click into a tab when the click is on top of a tab
+			/// </summary>
+			/// <param name="x"></param>
+			/// <param name="y"></param>
+			/// <returns></returns>
+			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;
+			}
+		}
+	}
+
+	/// <summary>
+	/// Describes a change in <see cref="TabView.SelectedTab"/>
+	/// </summary>
+	public class TabChangedEventArgs : EventArgs {
+
+		/// <summary>
+		/// The previously selected tab. May be null
+		/// </summary>
+		public Tab OldTab { get; }
+
+		/// <summary>
+		/// The currently selected tab. May be null
+		/// </summary>
+		public Tab NewTab { get; }
+
+		/// <summary>
+		/// Documents a tab change
+		/// </summary>
+		/// <param name="oldTab"></param>
+		/// <param name="newTab"></param>
+		public TabChangedEventArgs (Tab oldTab, Tab newTab)
+		{
+			OldTab = oldTab;
+			NewTab = newTab;
+		}
+	}
+}

+ 250 - 0
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));
+				}
+			}
+
+		}
+
+		/// <summary>
+		/// Creates a new tab with initial text
+		/// </summary>
+		/// <param name="initialText"></param>
+		/// <param name="fileInfo">File that was read or null if a new blank document</param>
+		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; }
+
+			/// <summary>
+			/// The text of the tab the last time it was saved
+			/// </summary>
+			/// <value></value>
+			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 ();
+		}
+	}
+}

+ 206 - 0
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 ();
+		}
+	}
+}

+ 216 - 0
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 (() => { });
+		}
+	}
+}