using NStack;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
namespace Terminal.Gui.Views {
///
/// Describes how to render a given column in a including and textual representation of cells (e.g. date formats)
///
public class ColumnStyle {
///
/// Defines the default alignment for all values rendered in this column. For custom alignment based on cell contents use .
///
public TextAlignment Alignment {get;set;}
///
/// Defines a delegate for returning custom alignment per cell based on cell values. When specified this will override
///
public Func AlignmentGetter;
///
/// Defines a delegate for returning custom representations of cell values. If not set then is used. Return values from your delegate may be truncated e.g. based on
///
public Func RepresentationGetter;
///
/// Defines the format for values e.g. "yyyy-MM-dd" for dates
///
public string Format{get;set;}
///
/// Set the maximum width of the column in characters. This value will be ignored if more than the tables . Defaults to
///
public int MaxWidth {get;set;} = TableView.DefaultMaxCellWidth;
///
/// Set the minimum width of the column in characters. This value will be ignored if more than the tables or the
///
public int MinWidth {get;set;}
///
/// Returns the alignment for the cell based on and /
///
///
///
public TextAlignment GetAlignment(object cellValue)
{
if(AlignmentGetter != null)
return AlignmentGetter(cellValue);
return Alignment;
}
///
/// Returns the full string to render (which may be truncated if too long) that the current style says best represents the given
///
///
///
public string GetRepresentation (object value)
{
if(!string.IsNullOrWhiteSpace(Format)) {
if(value is IFormattable f)
return f.ToString(Format,null);
}
if(RepresentationGetter != null)
return RepresentationGetter(value);
return value?.ToString();
}
}
///
/// Defines rendering options that affect how the table is displayed
///
public class TableStyle {
///
/// When scrolling down always lock the column headers in place as the first row of the table
///
public bool AlwaysShowHeaders {get;set;} = false;
///
/// True to render a solid line above the headers
///
public bool ShowHorizontalHeaderOverline {get;set;} = true;
///
/// True to render a solid line under the headers
///
public bool ShowHorizontalHeaderUnderline {get;set;} = true;
///
/// True to render a solid line vertical line between cells
///
public bool ShowVerticalCellLines {get;set;} = true;
///
/// True to render a solid line vertical line between headers
///
public bool ShowVerticalHeaderLines {get;set;} = true;
///
/// Collection of columns for which you want special rendering (e.g. custom column lengths, text alignment etc)
///
public Dictionary ColumnStyles {get;set; } = new Dictionary();
///
/// Returns the entry from for the given or null if no custom styling is defined for it
///
///
///
public ColumnStyle GetColumnStyleIfAny (DataColumn col)
{
return ColumnStyles.TryGetValue(col,out ColumnStyle result) ? result : null;
}
}
///
/// View for tabular data based on a
///
public class TableView : View {
private int columnOffset;
private int rowOffset;
private int selectedRow;
private int selectedColumn;
private DataTable table;
private TableStyle style = new TableStyle();
///
/// The default maximum cell width for and
///
public const int DefaultMaxCellWidth = 100;
///
/// The data table to render in the view. Setting this property automatically updates and redraws the control.
///
public DataTable Table { get => table; set {table = value; Update(); } }
///
/// Contains options for changing how the table is rendered
///
public TableStyle Style { get => style; set {style = value; Update(); } }
///
/// Zero indexed offset for the upper left to display in .
///
/// This property allows very wide tables to be rendered with horizontal scrolling
public int ColumnOffset {
get => columnOffset;
//try to prevent this being set to an out of bounds column
set => columnOffset = Table == null ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value));
}
///
/// Zero indexed offset for the to display in on line 2 of the control (first line being headers)
///
/// This property allows very wide tables to be rendered with horizontal scrolling
public int RowOffset {
get => rowOffset;
set => rowOffset = Table == null ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value));
}
///
/// The index of in that the user has currently selected
///
public int SelectedColumn {
get => selectedColumn;
//try to prevent this being set to an out of bounds column
set => selectedColumn = Table == null ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value));
}
///
/// The index of in that the user has currently selected
///
public int SelectedRow {
get => selectedRow;
set => selectedRow = Table == null ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value));
}
///
/// The maximum number of characters to render in any given column. This prevents one long column from pushing out all the others
///
public int MaxCellWidth { get; set; } = DefaultMaxCellWidth;
///
/// The text representation that should be rendered for cells with the value
///
public string NullSymbol { get; set; } = "-";
///
/// The symbol to add after each cell value and header value to visually seperate values (if not using vertical gridlines)
///
public char SeparatorSymbol { get; set; } = ' ';
///
/// Initialzies a class using layout.
///
/// The table to display in the control
public TableView (DataTable table) : this ()
{
this.Table = table;
}
///
/// Initialzies a class using layout. Set the property to begin editing
///
public TableView () : base ()
{
CanFocus = true;
}
///
public override void Redraw (Rect bounds)
{
Move (0, 0);
var frame = Frame;
// What columns to render at what X offset in viewport
var columnsToRender = CalculateViewport(bounds).ToArray();
Driver.SetAttribute (ColorScheme.Normal);
//invalidate current row (prevents scrolling around leaving old characters in the frame
Driver.AddStr (new string (' ', bounds.Width));
int line = 0;
if(ShouldRenderHeaders()){
// Render something like:
/*
┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐
│ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│
└────────────────────┴──────────┴───────────┴──────────────┴─────────┘
*/
if(Style.ShowHorizontalHeaderOverline){
RenderHeaderOverline(line,bounds.Width,columnsToRender);
line++;
}
RenderHeaderMidline(line,columnsToRender);
line++;
if(Style.ShowHorizontalHeaderUnderline){
RenderHeaderUnderline(line,bounds.Width,columnsToRender);
line++;
}
}
//render the cells
for (; line < frame.Height; line++) {
ClearLine(line,bounds.Width);
//work out what Row to render
var rowToRender = RowOffset + (line - GetHeaderHeight());
//if we have run off the end of the table
if ( Table == null || rowToRender >= Table.Rows.Count || rowToRender < 0)
continue;
RenderRow(line,rowToRender,columnsToRender);
}
}
///
/// Clears a line of the console by filling it with spaces
///
///
///
private void ClearLine(int row, int width)
{
Move (0, row);
Driver.SetAttribute (ColorScheme.Normal);
Driver.AddStr (new string (' ', width));
}
///
/// Returns the amount of vertical space required to display the header
///
///
private int GetHeaderHeight()
{
int heightRequired = 1;
if(Style.ShowHorizontalHeaderOverline)
heightRequired++;
if(Style.ShowHorizontalHeaderUnderline)
heightRequired++;
return heightRequired;
}
private void RenderHeaderOverline(int row,int availableWidth, ColumnToRender[] columnsToRender)
{
// Renders a line above table headers (when visible) like:
// ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐
for(int c = 0;c< availableWidth;c++) {
var rune = Driver.HLine;
if (Style.ShowVerticalHeaderLines){
if(c == 0){
rune = Driver.ULCorner;
}
// if the next column is the start of a header
else if(columnsToRender.Any(r=>r.X == c+1)){
rune = Driver.TopTee;
}
else if(c == availableWidth -1){
rune = Driver.URCorner;
}
}
AddRuneAt(Driver,c,row,rune);
}
}
private void RenderHeaderMidline(int row, ColumnToRender[] columnsToRender)
{
// Renders something like:
// │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│
ClearLine(row,Bounds.Width);
//render start of line
if(style.ShowVerticalHeaderLines)
AddRune(0,row,Driver.VLine);
for(int i =0 ; i
/// Calculates how much space is available to render index of the given the remaining horizontal space
///
///
///
private int GetCellWidth (ColumnToRender [] columnsToRender, int i)
{
var current = columnsToRender[i];
var next = i+1 < columnsToRender.Length ? columnsToRender[i+1] : null;
if(next == null) {
// cell can fill to end of the line
return Bounds.Width - current.X;
}
else {
// cell can fill up to next cell start
return next.X - current.X;
}
}
private void RenderHeaderUnderline(int row,int availableWidth, ColumnToRender[] columnsToRender)
{
// Renders a line below the table headers (when visible) like:
// ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤
for(int c = 0;c< availableWidth;c++) {
var rune = Driver.HLine;
if (Style.ShowVerticalHeaderLines){
if(c == 0){
rune = Style.ShowVerticalCellLines ? Driver.LeftTee : Driver.LLCorner;
}
// if the next column is the start of a header
else if(columnsToRender.Any(r=>r.X == c+1)){
/*TODO: is ┼ symbol in Driver?*/
rune = Style.ShowVerticalCellLines ? '┼' :Driver.BottomTee;
}
else if(c == availableWidth -1){
rune = Style.ShowVerticalCellLines ? Driver.RightTee : Driver.LRCorner;
}
}
AddRuneAt(Driver,c,row,rune);
}
}
private void RenderRow(int row, int rowToRender, ColumnToRender[] columnsToRender)
{
//render start of line
if(style.ShowVerticalCellLines)
AddRune(0,row,Driver.VLine);
// Render cells for each visible header for the current row
for(int i=0;i< columnsToRender.Length ;i++) {
var current = columnsToRender[i];
var availableWidthForCell = GetCellWidth(columnsToRender,i);
var colStyle = Style.GetColumnStyleIfAny(current.Column);
// move to start of cell (in line with header positions)
Move (current.X, row);
// Set color scheme based on whether the current cell is the selected one
bool isSelectedCell = rowToRender == SelectedRow && current.Column.Ordinal == SelectedColumn;
Driver.SetAttribute (isSelectedCell ? ColorScheme.HotFocus : ColorScheme.Normal);
var val = Table.Rows [rowToRender][current.Column];
// Render the (possibly truncated) cell value
var representation = GetRepresentation(val,colStyle);
Driver.AddStr (TruncateOrPad(val,representation,availableWidthForCell,colStyle));
// Reset color scheme to normal and render the vertical line (or space) at the end of the cell
Driver.SetAttribute (ColorScheme.Normal);
RenderSeparator(current.X-1,row,false);
}
//render end of line
if(style.ShowVerticalCellLines)
AddRune(Bounds.Width-1,row,Driver.VLine);
}
private void RenderSeparator(int col, int row,bool isHeader)
{
if(col<0)
return;
var renderLines = isHeader ? style.ShowVerticalHeaderLines : style.ShowVerticalCellLines;
Rune symbol = renderLines ? Driver.VLine : SeparatorSymbol;
AddRune(col,row,symbol);
}
void AddRuneAt (ConsoleDriver d,int col, int row, Rune ch)
{
Move (col, row);
d.AddRune (ch);
}
///
/// Truncates or pads so that it occupies a exactly using the alignment specified in (or left if no style is defined)
///
/// The object in this cell of the
/// The string representation of
///
/// Optional style indicating custom alignment for the cell
///
private string TruncateOrPad (object originalCellValue,string representation, int availableHorizontalSpace, ColumnStyle colStyle)
{
if (string.IsNullOrEmpty (representation))
return representation;
// if value is not wide enough
if(representation.Length < availableHorizontalSpace) {
// pad it out with spaces to the given alignment
int toPad = availableHorizontalSpace - (representation.Length+1 /*leave 1 space for cell boundary*/);
switch(colStyle?.GetAlignment(originalCellValue) ?? TextAlignment.Left) {
case TextAlignment.Left :
return representation + new string(' ',toPad);
case TextAlignment.Right :
return new string(' ',toPad) + representation;
// TODO: With single line cells, centered and justified are the same right?
case TextAlignment.Centered :
case TextAlignment.Justified :
return
new string(' ',(int)Math.Floor(toPad/2.0)) + // round down
representation +
new string(' ',(int)Math.Ceiling(toPad/2.0)) ; // round up
}
}
// value is too wide
return representation.Substring (0, availableHorizontalSpace);
}
///
public override bool ProcessKey (KeyEvent keyEvent)
{
switch (keyEvent.Key) {
case Key.CursorLeft:
SelectedColumn--;
Update ();
break;
case Key.CursorRight:
SelectedColumn++;
Update ();
break;
case Key.CursorDown:
SelectedRow++;
Update ();
break;
case Key.CursorUp:
SelectedRow--;
Update ();
break;
case Key.PageUp:
SelectedRow -= Frame.Height;
Update ();
break;
case Key.PageDown:
SelectedRow += Frame.Height;
Update ();
break;
case Key.Home | Key.CtrlMask:
SelectedRow = 0;
SelectedColumn = 0;
Update ();
break;
case Key.Home:
SelectedColumn = 0;
Update ();
break;
case Key.End | Key.CtrlMask:
//jump to end of table
SelectedRow = Table == null ? 0 : Table.Rows.Count - 1;
SelectedColumn = Table == null ? 0 : Table.Columns.Count - 1;
Update ();
break;
case Key.End:
//jump to end of row
SelectedColumn = Table == null ? 0 : Table.Columns.Count - 1;
Update ();
break;
default:
// Not a keystroke we care about
return false;
}
PositionCursor ();
return true;
}
///
public override bool MouseEvent (MouseEvent me)
{
if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) &&
me.Flags != MouseFlags.WheeledDown && me.Flags != MouseFlags.WheeledUp)
return false;
if (!HasFocus && CanFocus) {
SetFocus ();
}
if (Table == null) {
return false;
}
if (me.Flags == MouseFlags.WheeledDown) {
RowOffset++;
Update ();
return true;
} else if (me.Flags == MouseFlags.WheeledUp) {
RowOffset--;
Update ();
return true;
}
if(me.Flags == MouseFlags.Button1Clicked) {
var viewPort = CalculateViewport(Bounds);
var headerHeight = GetHeaderHeight();
var col = viewPort.LastOrDefault(c=>c.X < me.OfX);
var rowIdx = RowOffset - headerHeight + me.OfY;
if(col != null && rowIdx >= 0) {
SelectedRow = rowIdx;
SelectedColumn = col.Column.Ordinal;
Update();
}
}
return false;
}
///
/// Updates the view to reflect changes to and to ( / ) etc
///
/// This always calls
public void Update()
{
if(Table == null) {
SetNeedsDisplay ();
return;
}
//if user opened a large table scrolled down a lot then opened a smaller table (or API deleted a bunch of columns without telling anyone)
ColumnOffset = Math.Max(Math.Min(ColumnOffset,Table.Columns.Count -1),0);
RowOffset = Math.Max(Math.Min(RowOffset,Table.Rows.Count -1),0);
SelectedColumn = Math.Max(Math.Min(SelectedColumn,Table.Columns.Count -1),0);
SelectedRow = Math.Max(Math.Min(SelectedRow,Table.Rows.Count -1),0);
var columnsToRender = CalculateViewport (Bounds).ToArray();
var headerHeight = GetHeaderHeight();
//if we have scrolled too far to the left
if (SelectedColumn < columnsToRender.Min (r => r.Column.Ordinal)) {
ColumnOffset = SelectedColumn;
}
//if we have scrolled too far to the right
if (SelectedColumn > columnsToRender.Max (r=> r.Column.Ordinal)) {
ColumnOffset = SelectedColumn;
}
//if we have scrolled too far down
if (SelectedRow >= RowOffset + (Bounds.Height - headerHeight)) {
RowOffset = SelectedRow;
}
//if we have scrolled too far up
if (SelectedRow < RowOffset) {
RowOffset = SelectedRow;
}
SetNeedsDisplay ();
}
///
/// Calculates which columns should be rendered given the in which to display and the
///
///
///
///
private IEnumerable CalculateViewport (Rect bounds, int padding = 1)
{
if(Table == null)
yield break;
int usedSpace = 0;
//if horizontal space is required at the start of the line (before the first header)
if(Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines)
usedSpace+=1;
int availableHorizontalSpace = bounds.Width;
int rowsToRender = bounds.Height;
// reserved for the headers row
if(ShouldRenderHeaders())
rowsToRender -= GetHeaderHeight();
bool first = true;
foreach (var col in Table.Columns.Cast().Skip (ColumnOffset)) {
int startingIdxForCurrentHeader = usedSpace;
var colStyle = Style.GetColumnStyleIfAny(col);
// is there enough space for this column (and it's data)?
usedSpace += CalculateMaxCellWidth (col, rowsToRender,colStyle) + padding;
// no (don't render it) unless its the only column we are render (that must be one massively wide column!)
if (!first && usedSpace > availableHorizontalSpace)
yield break;
// there is space
yield return new ColumnToRender(col, startingIdxForCurrentHeader);
first=false;
}
}
private bool ShouldRenderHeaders()
{
if(Table == null || Table.Columns.Count == 0)
return false;
return Style.AlwaysShowHeaders || rowOffset == 0;
}
///
/// Returns the maximum of the name and the maximum length of data that will be rendered starting at and rendering
///
///
///
///
///
private int CalculateMaxCellWidth(DataColumn col, int rowsToRender,ColumnStyle colStyle)
{
int spaceRequired = col.ColumnName.Length;
// if table has no rows
if(RowOffset < 0)
return spaceRequired;
for (int i = RowOffset; i < RowOffset + rowsToRender && i < Table.Rows.Count; i++) {
//expand required space if cell is bigger than the last biggest cell or header
spaceRequired = Math.Max (spaceRequired, GetRepresentation(Table.Rows [i][col],colStyle).Length);
}
// Don't require more space than the style allows
if(colStyle != null){
// enforce maximum cell width based on style
if(spaceRequired > colStyle.MaxWidth) {
spaceRequired = colStyle.MaxWidth;
}
// enforce minimum cell width based on style
if(spaceRequired < colStyle.MinWidth) {
spaceRequired = colStyle.MinWidth;
}
}
// enforce maximum cell width based on global table style
if(spaceRequired > MaxCellWidth)
spaceRequired = MaxCellWidth;
return spaceRequired;
}
///
/// Returns the value that should be rendered to best represent a strongly typed read from
///
///
/// Optional style defining how to represent cell values
///
private string GetRepresentation(object value,ColumnStyle colStyle)
{
if (value == null || value == DBNull.Value) {
return NullSymbol;
}
return colStyle != null ? colStyle.GetRepresentation(value): value.ToString();
}
}
///
/// Describes a desire to render a column at a given horizontal position in the UI
///
internal class ColumnToRender {
///
/// The column to render
///
public DataColumn Column {get;set;}
///
/// The horizontal position to begin rendering the column at
///
public int X{get;set;}
public ColumnToRender (DataColumn col, int x)
{
Column = col;
X = x;
}
}
}