mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-26 15:57:56 +01:00
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:
839
Terminal.Gui/Views/TabView.cs
Normal file
839
Terminal.Gui/Views/TabView.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
250
UICatalog/Scenarios/Notepad.cs
Normal file
250
UICatalog/Scenarios/Notepad.cs
Normal 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 ();
|
||||
}
|
||||
}
|
||||
}
|
||||
206
UICatalog/Scenarios/TabViewExample.cs
Normal file
206
UICatalog/Scenarios/TabViewExample.cs
Normal 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
216
UnitTests/TabViewTests.cs
Normal 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 (() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user