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