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
This commit is contained in:
Thomas Nind
2021-03-24 17:06:56 +00:00
committed by GitHub
parent 482f9db7da
commit 1f01ff86fd
4 changed files with 1511 additions and 0 deletions

View File

@@ -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;
}
}
}

View File

@@ -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 ();
}
}
}

View File

@@ -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
UnitTests/TabViewTests.cs Normal file
View File

@@ -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 (() => { });
}
}
}