using NStack;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
namespace Terminal.Gui {
///
/// 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;
///
/// Event fired when a is clicked. Can be used to cancel navigation,
/// show context menu (e.g. on right click) etc.
///
public event EventHandler TabClicked;
///
/// 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 ();
///
/// Initializes a class using layout.
///
public TabView () : base ()
{
CanFocus = true;
contentView = new View ();
tabsBar = new TabRowView (this);
ApplyStyleChanges ();
base.Add (tabsBar);
base.Add (contentView);
// Things this view knows how to do
AddCommand (Command.Left, () => { SwitchTabBy (-1); return true; });
AddCommand (Command.Right, () => { SwitchTabBy (1); return true; });
AddCommand (Command.LeftHome, () => { SelectedTab = Tabs.FirstOrDefault (); return true; });
AddCommand (Command.RightEnd, () => { SelectedTab = Tabs.LastOrDefault (); return true; });
// Default keybindings for this view
AddKeyBinding (Key.CursorLeft, Command.Left);
AddKeyBinding (Key.CursorRight, Command.Right);
AddKeyBinding (Key.Home, Command.LeftHome);
AddKeyBinding (Key.End, Command.RightEnd);
}
///
/// 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 (GetNormalColor ());
if (Style.ShowBorder) {
// How much 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,
Math.Max (bounds.Height - spaceAtBottom - startAtY, 0)), 0, true);
}
if (Tabs.Any ()) {
tabsBar.Redraw (tabsBar.Bounds);
contentView.SetNeedsDisplay ();
var savedClip = contentView.ClipToBounds ();
contentView.Redraw (contentView.Bounds);
Driver.Clip = savedClip;
}
}
///
/// 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) {
var result = InvokeKeybindings (keyEvent);
if (result != null)
return (bool)result;
}
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 ();
// The maximum number of characters to use for the tab name as specified
// by the user (MaxTabTextWidth). But not more than the width of the view
// or we won't even be able to render a single tab!
var maxWidth = Math.Max (0, Math.Min (bounds.Width - 3, MaxTabTextWidth));
// if tab view is width <= 3 don't render any tabs
if (maxWidth == 0) {
yield return new TabToRender (i, tab, string.Empty, Equals (SelectedTab, tab), 0);
break;
}
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 disappeared
var toSelect = Math.Max (idx - 1, 0);
if (toSelect < Tabs.Count) {
SelectedTab = Tabs.ElementAt (toSelect);
} else {
SelectedTab = Tabs.LastOrDefault ();
}
}
EnsureSelectedTabIsVisible ();
SetNeedsDisplay ();
}
#region Nested Types
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 ();
}
public override bool OnEnter (View view)
{
Driver.SetCursorVisibility (CursorVisibility.Invisible);
return base.OnEnter (view);
}
public override void Redraw (Rect bounds)
{
base.Redraw (bounds);
var tabLocations = host.CalculateViewport (bounds).ToArray ();
var width = bounds.Width;
Driver.SetAttribute (GetNormalColor ());
if (host.Style.ShowTopLine) {
RenderOverline (tabLocations, width);
}
RenderTabLine (tabLocations, width);
RenderUnderline (tabLocations, width);
Driver.SetAttribute (GetNormalColor ());
}
///
/// 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 (GetNormalColor ());
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 more 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)
{
var hit = ScreenToTab (me.X, me.Y);
bool isClick = me.Flags.HasFlag (MouseFlags.Button1Clicked) ||
me.Flags.HasFlag (MouseFlags.Button2Clicked) ||
me.Flags.HasFlag (MouseFlags.Button3Clicked);
if (isClick) {
host.OnTabClicked (new TabMouseEventArgs (hit, me));
// user canceled click
if (me.Handled) {
return true;
}
}
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;
}
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;
}
}
///
/// Raises the event.
///
///
protected virtual private void OnTabClicked (TabMouseEventArgs tabMouseEventArgs)
{
TabClicked?.Invoke (this, tabMouseEventArgs);
}
///
/// Describes a mouse event over a specific in a .
///
public class TabMouseEventArgs : EventArgs {
///
/// Gets the (if any) that the mouse
/// was over when the occurred.
///
/// This will be null if the click is after last tab
/// or before first.
public Tab Tab { get; }
///
/// Gets the actual mouse event. Use to cancel this event
/// and perform custom behavior (e.g. show a context menu).
///
public MouseEvent MouseEvent { get; }
///
/// Creates a new instance of the class.
///
/// that the mouse was over when the event occurred.
/// The mouse activity being reported
public TabMouseEventArgs (Tab tab, MouseEvent mouseEvent)
{
Tab = tab;
MouseEvent = mouseEvent;
}
}
///
/// 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;
}
///
/// 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;
}
}
#endregion
}
}