using NStack;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
namespace Terminal.Gui {
///
/// View for tabular data based on a .
///
/// See TableView Deep Dive for more information.
///
public partial class TableView : View {
private int columnOffset;
private int rowOffset;
private int selectedRow;
private int selectedColumn;
private DataTable table;
private TableStyle style = new TableStyle ();
private Key cellActivationKey = Key.Enter;
Point? scrollLeftPoint;
Point? scrollRightPoint;
///
/// The default maximum cell width for and
///
public const int DefaultMaxCellWidth = 100;
///
/// The default minimum cell width for
///
public const int DefaultMinAcceptableWidth = 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 (); } }
///
/// True to select the entire row at once. False to select individual cells. Defaults to false
///
public bool FullRowSelect { get; set; }
///
/// True to allow regions to be selected
///
///
public bool MultiSelect { get; set; } = true;
///
/// When is enabled this property contain all rectangles of selected cells. Rectangles describe column/rows selected in (not screen coordinates)
///
///
public Stack MultiSelectedRegions { get; private set; } = new Stack ();
///
/// Horizontal scroll offset. The index of the first column in to display when when rendering the view.
///
/// 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 = TableIsNullOrInvisible () ? 0 : Math.Max (0, Math.Min (Table.Columns.Count - 1, value));
}
///
/// Vertical scroll offset. The index of the first row in to display in the first non header line of the control when rendering the view.
///
public int RowOffset {
get => rowOffset;
set => rowOffset = TableIsNullOrInvisible () ? 0 : Math.Max (0, Math.Min (Table.Rows.Count - 1, value));
}
///
/// The index of in that the user has currently selected
///
public int SelectedColumn {
get => selectedColumn;
set {
var oldValue = selectedColumn;
//try to prevent this being set to an out of bounds column
selectedColumn = TableIsNullOrInvisible () ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value));
if (oldValue != selectedColumn)
OnSelectedCellChanged (new SelectedCellChangedEventArgs (Table, oldValue, SelectedColumn, SelectedRow, SelectedRow));
}
}
///
/// The index of in that the user has currently selected
///
public int SelectedRow {
get => selectedRow;
set {
var oldValue = selectedRow;
selectedRow = TableIsNullOrInvisible () ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value));
if (oldValue != selectedRow)
OnSelectedCellChanged (new SelectedCellChangedEventArgs (Table, SelectedColumn, SelectedColumn, oldValue, selectedRow));
}
}
///
/// 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; } = ' ';
///
/// This event is raised when the selected cell in the table changes.
///
public event EventHandler SelectedCellChanged;
///
/// This event is raised when a cell is activated e.g. by double clicking or pressing
///
public event EventHandler CellActivated;
///
/// The key which when pressed should trigger event. Defaults to Enter.
///
public Key CellActivationKey {
get => cellActivationKey;
set {
if (cellActivationKey != value) {
ReplaceKeyBinding (cellActivationKey, value);
// of API user is mixing and matching old and new methods of keybinding then they may have lost
// the old binding (e.g. with ClearKeybindings) so ReplaceKeyBinding alone will fail
AddKeyBinding (value, Command.Accept);
cellActivationKey = value;
}
}
}
///
/// 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;
// Things this view knows how to do
AddCommand (Command.Right, () => { ChangeSelectionByOffset (1, 0, false); return true; });
AddCommand (Command.Left, () => { ChangeSelectionByOffset (-1, 0, false); return true; });
AddCommand (Command.LineUp, () => { ChangeSelectionByOffset (0, -1, false); return true; });
AddCommand (Command.LineDown, () => { ChangeSelectionByOffset (0, 1, false); return true; });
AddCommand (Command.PageUp, () => { PageUp (false); return true; });
AddCommand (Command.PageDown, () => { PageDown (false); return true; });
AddCommand (Command.LeftHome, () => { ChangeSelectionToStartOfRow (false); return true; });
AddCommand (Command.RightEnd, () => { ChangeSelectionToEndOfRow (false); return true; });
AddCommand (Command.TopHome, () => { ChangeSelectionToStartOfTable (false); return true; });
AddCommand (Command.BottomEnd, () => { ChangeSelectionToEndOfTable (false); return true; });
AddCommand (Command.RightExtend, () => { ChangeSelectionByOffset (1, 0, true); return true; });
AddCommand (Command.LeftExtend, () => { ChangeSelectionByOffset (-1, 0, true); return true; });
AddCommand (Command.LineUpExtend, () => { ChangeSelectionByOffset (0, -1, true); return true; });
AddCommand (Command.LineDownExtend, () => { ChangeSelectionByOffset (0, 1, true); return true; });
AddCommand (Command.PageUpExtend, () => { PageUp (true); return true; });
AddCommand (Command.PageDownExtend, () => { PageDown (true); return true; });
AddCommand (Command.LeftHomeExtend, () => { ChangeSelectionToStartOfRow (true); return true; });
AddCommand (Command.RightEndExtend, () => { ChangeSelectionToEndOfRow (true); return true; });
AddCommand (Command.TopHomeExtend, () => { ChangeSelectionToStartOfTable (true); return true; });
AddCommand (Command.BottomEndExtend, () => { ChangeSelectionToEndOfTable (true); return true; });
AddCommand (Command.SelectAll, () => { SelectAll (); return true; });
AddCommand (Command.Accept, () => { OnCellActivated (new CellActivatedEventArgs (Table, SelectedColumn, SelectedRow)); return true; });
AddCommand (Command.ToggleChecked, () => { ToggleCurrentCellSelection (); return true; });
// Default keybindings for this view
AddKeyBinding (Key.CursorLeft, Command.Left);
AddKeyBinding (Key.CursorRight, Command.Right);
AddKeyBinding (Key.CursorUp, Command.LineUp);
AddKeyBinding (Key.CursorDown, Command.LineDown);
AddKeyBinding (Key.PageUp, Command.PageUp);
AddKeyBinding (Key.PageDown, Command.PageDown);
AddKeyBinding (Key.Home, Command.LeftHome);
AddKeyBinding (Key.End, Command.RightEnd);
AddKeyBinding (Key.Home | Key.CtrlMask, Command.TopHome);
AddKeyBinding (Key.End | Key.CtrlMask, Command.BottomEnd);
AddKeyBinding (Key.CursorLeft | Key.ShiftMask, Command.LeftExtend);
AddKeyBinding (Key.CursorRight | Key.ShiftMask, Command.RightExtend);
AddKeyBinding (Key.CursorUp | Key.ShiftMask, Command.LineUpExtend);
AddKeyBinding (Key.CursorDown | Key.ShiftMask, Command.LineDownExtend);
AddKeyBinding (Key.PageUp | Key.ShiftMask, Command.PageUpExtend);
AddKeyBinding (Key.PageDown | Key.ShiftMask, Command.PageDownExtend);
AddKeyBinding (Key.Home | Key.ShiftMask, Command.LeftHomeExtend);
AddKeyBinding (Key.End | Key.ShiftMask, Command.RightEndExtend);
AddKeyBinding (Key.Home | Key.CtrlMask | Key.ShiftMask, Command.TopHomeExtend);
AddKeyBinding (Key.End | Key.CtrlMask | Key.ShiftMask, Command.BottomEndExtend);
AddKeyBinding (Key.A | Key.CtrlMask, Command.SelectAll);
AddKeyBinding (CellActivationKey, Command.Accept);
}
///
public override void Redraw (Rect bounds)
{
Move (0, 0);
var frame = Frame;
scrollRightPoint = null;
scrollLeftPoint = null;
// What columns to render at what X offset in viewport
var columnsToRender = CalculateViewport (bounds).ToArray ();
Driver.SetAttribute (GetNormalColor ());
//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++;
}
}
int headerLinesConsumed = line;
//render the cells
for (; line < frame.Height; line++) {
ClearLine (line, bounds.Width);
//work out what Row to render
var rowToRender = RowOffset + (line - headerLinesConsumed);
//if we have run off the end of the table
if (TableIsNullOrInvisible () || 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 (GetNormalColor ());
Driver.AddStr (new string (' ', width));
}
///
/// Returns the amount of vertical space currently occupied by the header or 0 if it is not visible.
///
///
private int GetHeaderHeightIfAny ()
{
return ShouldRenderHeaders () ? GetHeaderHeight () : 0;
}
///
/// 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;
}
// if the next console column is the lastcolumns end
else if (Style.ExpandLastColumn == false &&
columnsToRender.Any (r => r.IsVeryLast && r.X + r.Width - 1 == c)) {
rune = Driver.TopTee;
}
}
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 < columnsToRender.Length; i++) {
var current = columnsToRender [i];
var colStyle = Style.GetColumnStyleIfAny (current.Column);
var colName = current.Column.ColumnName;
RenderSeparator (current.X - 1, row, true);
Move (current.X, row);
Driver.AddStr (TruncateOrPad (colName, colName, current.Width, colStyle));
if (Style.ExpandLastColumn == false && current.IsVeryLast) {
RenderSeparator (current.X + current.Width - 1, row, true);
}
}
//render end of line
if (style.ShowVerticalHeaderLines)
AddRune (Bounds.Width - 1, row, Driver.VLine);
}
private void RenderHeaderUnderline (int row, int availableWidth, ColumnToRender [] columnsToRender)
{
/*
* First lets work out if we should be rendering scroll indicators
*/
// are there are visible columns to the left that have been pushed
// off the screen due to horizontal scrolling?
bool moreColumnsToLeft = ColumnOffset > 0;
// if we moved left would we find a new column (or are they all invisible?)
if (!TryGetNearestVisibleColumn (ColumnOffset - 1, false, false, out _)) {
moreColumnsToLeft = false;
}
// are there visible columns to the right that have not yet been reached?
// lets find out, what is the column index of the last column we are rendering
int lastColumnIdxRendered = ColumnOffset + columnsToRender.Length - 1;
// are there more valid indexes?
bool moreColumnsToRight = lastColumnIdxRendered < Table.Columns.Count;
// if we went right from the last column would we find a new visible column?
if (!TryGetNearestVisibleColumn (lastColumnIdxRendered + 1, true, false, out _)) {
// no we would not
moreColumnsToRight = false;
}
/*
* Now lets draw the line itself
*/
// Renders a line below the table headers (when visible) like:
// ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤
for (int c = 0; c < availableWidth; c++) {
// Start by assuming we just draw a straight line the
// whole way but update to instead draw a header indicator
// or scroll arrow etc
var rune = Driver.HLine;
if (Style.ShowVerticalHeaderLines) {
if (c == 0) {
// for first character render line
rune = Style.ShowVerticalCellLines ? Driver.LeftTee : Driver.LLCorner;
// unless we have horizontally scrolled along
// in which case render an arrow, to indicate user
// can scroll left
if (Style.ShowHorizontalScrollIndicators && moreColumnsToLeft) {
rune = Driver.LeftArrow;
scrollLeftPoint = new Point (c, row);
}
}
// 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) {
// for the last character in the table
rune = Style.ShowVerticalCellLines ? Driver.RightTee : Driver.LRCorner;
// unless there is more of the table we could horizontally
// scroll along to see. In which case render an arrow,
// to indicate user can scroll right
if (Style.ShowHorizontalScrollIndicators && moreColumnsToRight) {
rune = Driver.RightArrow;
scrollRightPoint = new Point (c, row);
}
}
// if the next console column is the lastcolumns end
else if (Style.ExpandLastColumn == false &&
columnsToRender.Any (r => r.IsVeryLast && r.X + r.Width - 1 == c)) {
rune = Style.ShowVerticalCellLines ? '┼' : Driver.BottomTee;
}
}
AddRuneAt (Driver, c, row, rune);
}
}
private void RenderRow (int row, int rowToRender, ColumnToRender [] columnsToRender)
{
var focused = HasFocus;
var rowScheme = (Style.RowColorGetter?.Invoke (
new RowColorGetterArgs (Table, rowToRender))) ?? ColorScheme;
//render start of line
if (style.ShowVerticalCellLines)
AddRune (0, row, Driver.VLine);
//start by clearing the entire line
Move (0, row);
Attribute color;
if (FullRowSelect && IsSelected (0, rowToRender)) {
color = focused ? rowScheme.HotFocus : rowScheme.HotNormal;
} else {
color = Enabled ? rowScheme.Normal : rowScheme.Disabled;
}
Driver.SetAttribute (color);
Driver.AddStr (new string (' ', Bounds.Width));
// Render cells for each visible header for the current row
for (int i = 0; i < columnsToRender.Length; i++) {
var current = 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 = IsSelected (current.Column.Ordinal, rowToRender);
var val = Table.Rows [rowToRender] [current.Column];
// Render the (possibly truncated) cell value
var representation = GetRepresentation (val, colStyle);
// to get the colour scheme
var colorSchemeGetter = colStyle?.ColorGetter;
ColorScheme scheme;
if (colorSchemeGetter != null) {
// user has a delegate for defining row color per cell, call it
scheme = colorSchemeGetter (
new CellColorGetterArgs (Table, rowToRender, current.Column.Ordinal, val, representation, rowScheme));
// if users custom color getter returned null, use the row scheme
if (scheme == null) {
scheme = rowScheme;
}
} else {
// There is no custom cell coloring delegate so use the scheme for the row
scheme = rowScheme;
}
Attribute cellColor;
if (isSelectedCell) {
cellColor = focused ? scheme.HotFocus : scheme.HotNormal;
} else {
cellColor = Enabled ? scheme.Normal : scheme.Disabled;
}
var render = TruncateOrPad (val, representation, current.Width, colStyle);
// While many cells can be selected (see MultiSelectedRegions) only one cell is the primary (drives navigation etc)
bool isPrimaryCell = current.Column.Ordinal == selectedColumn && rowToRender == selectedRow;
RenderCell (cellColor, render, isPrimaryCell);
// Reset color scheme to normal for drawing separators if we drew text with custom scheme
if (scheme != rowScheme) {
if (isSelectedCell) {
color = focused ? rowScheme.HotFocus : rowScheme.HotNormal;
} else {
color = Enabled ? rowScheme.Normal : rowScheme.Disabled;
}
Driver.SetAttribute (color);
}
// If not in full row select mode always, reset color scheme to normal and render the vertical line (or space) at the end of the cell
if (!FullRowSelect)
Driver.SetAttribute (Enabled ? rowScheme.Normal : rowScheme.Disabled);
RenderSeparator (current.X - 1, row, false);
if (Style.ExpandLastColumn == false && current.IsVeryLast) {
RenderSeparator (current.X + current.Width - 1, row, false);
}
}
//render end of line
if (style.ShowVerticalCellLines)
AddRune (Bounds.Width - 1, row, Driver.VLine);
}
///
/// Override to provide custom multi colouring to cells. Use to
/// with . The driver will already be
/// in the correct place when rendering and you must render the full
/// or the view will not look right. For simpler provision of color use
/// For changing the content that is rendered use
///
///
///
///
protected virtual void RenderCell (Attribute cellColor, string render, bool isPrimaryCell)
{
// If the cell is the selected col/row then draw the first rune in inverted colors
// this allows the user to track which cell is the active one during a multi cell
// selection or in full row select mode
if (Style.InvertSelectedCellFirstCharacter && isPrimaryCell) {
if (render.Length > 0) {
// invert the color of the current cell for the first character
Driver.SetAttribute (Driver.MakeAttribute (cellColor.Background, cellColor.Foreground));
Driver.AddRune (render [0]);
if (render.Length > 1) {
Driver.SetAttribute (cellColor);
Driver.AddStr (render.Substring (1));
}
}
} else {
Driver.SetAttribute (cellColor);
Driver.AddStr (render);
}
}
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 new string(' ',availableHorizontalSpace);
// if value is not wide enough
if (representation.Sum (c => Rune.ColumnWidth (c)) < availableHorizontalSpace) {
// pad it out with spaces to the given alignment
int toPad = availableHorizontalSpace - (representation.Sum (c => Rune.ColumnWidth (c)) + 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 new string (representation.TakeWhile (c => (availableHorizontalSpace -= Rune.ColumnWidth (c)) > 0).ToArray ());
}
///
public override bool ProcessKey (KeyEvent keyEvent)
{
if (TableIsNullOrInvisible ()) {
PositionCursor ();
return false;
}
var result = InvokeKeybindings (keyEvent);
if (result != null) {
PositionCursor ();
return true;
}
return false;
}
///
/// Moves the and to the given col/row in . Optionally starting a box selection (see )
///
///
///
/// True to create a multi cell selection or adjust an existing one
public void SetSelection (int col, int row, bool extendExistingSelection)
{
// if we are trying to increase the column index then
// we are moving right otherwise we are moving left
bool lookRight = col > selectedColumn;
col = GetNearestVisibleColumn (col, lookRight, true);
if (!MultiSelect || !extendExistingSelection) {
ClearMultiSelectedRegions (true);
}
if (extendExistingSelection) {
// If we are extending current selection but there isn't one
if (MultiSelectedRegions.Count == 0 || MultiSelectedRegions.All(m=>m.IsToggled)) {
// Create a new region between the old active cell and the new cell
var rect = CreateTableSelection (SelectedColumn, SelectedRow, col, row);
MultiSelectedRegions.Push (rect);
} else {
// Extend the current head selection to include the new cell
var head = MultiSelectedRegions.Pop ();
var newRect = CreateTableSelection (head.Origin.X, head.Origin.Y, col, row);
MultiSelectedRegions.Push (newRect);
}
}
SelectedColumn = col;
SelectedRow = row;
}
private void ClearMultiSelectedRegions (bool keepToggledSelections)
{
if (!keepToggledSelections) {
MultiSelectedRegions.Clear ();
return;
}
var oldRegions = MultiSelectedRegions.ToArray ().Reverse ();
MultiSelectedRegions.Clear ();
foreach (var region in oldRegions) {
if (region.IsToggled) {
MultiSelectedRegions.Push (region);
}
}
}
///
/// Unions the current selected cell (and/or regions) with the provided cell and makes
/// it the active one.
///
///
///
private void UnionSelection (int col, int row)
{
if (!MultiSelect || TableIsNullOrInvisible ()) {
return;
}
EnsureValidSelection ();
var oldColumn = SelectedColumn;
var oldRow = SelectedRow;
// move us to the new cell
SelectedColumn = col;
SelectedRow = row;
MultiSelectedRegions.Push (
CreateTableSelection (col, row)
);
// if the old cell was not part of a rectangular select
// or otherwise selected we need to retain it in the selection
if (!IsSelected (oldColumn, oldRow)) {
MultiSelectedRegions.Push (
CreateTableSelection (oldColumn, oldRow)
);
}
}
///
/// Moves the and by the provided offsets. Optionally starting a box selection (see )
///
/// Offset in number of columns
/// Offset in number of rows
/// True to create a multi cell selection or adjust an existing one
public void ChangeSelectionByOffset (int offsetX, int offsetY, bool extendExistingSelection)
{
SetSelection (SelectedColumn + offsetX, SelectedRow + offsetY, extendExistingSelection);
Update ();
}
///
/// Moves the selection up by one page
///
/// true to extend the current selection (if any) instead of replacing
public void PageUp (bool extend)
{
ChangeSelectionByOffset (0, -(Bounds.Height - GetHeaderHeightIfAny ()), extend);
Update ();
}
///
/// Moves the selection down by one page
///
/// true to extend the current selection (if any) instead of replacing
public void PageDown (bool extend)
{
ChangeSelectionByOffset (0, Bounds.Height - GetHeaderHeightIfAny (), extend);
Update ();
}
///
/// Moves or extends the selection to the first cell in the table (0,0).
/// If is enabled then selection instead moves
/// to (,0) i.e. no horizontal scrolling.
///
/// true to extend the current selection (if any) instead of replacing
public void ChangeSelectionToStartOfTable (bool extend)
{
SetSelection (FullRowSelect ? SelectedColumn : 0, 0, extend);
Update ();
}
///
/// Moves or extends the selection to the final cell in the table (nX,nY).
/// If is enabled then selection instead moves
/// to (,nY) i.e. no horizontal scrolling.
///
/// true to extend the current selection (if any) instead of replacing
public void ChangeSelectionToEndOfTable (bool extend)
{
var finalColumn = Table.Columns.Count - 1;
SetSelection (FullRowSelect ? SelectedColumn : finalColumn, Table.Rows.Count - 1, extend);
Update ();
}
///
/// Moves or extends the selection to the last cell in the current row
///
/// true to extend the current selection (if any) instead of replacing
public void ChangeSelectionToEndOfRow (bool extend)
{
SetSelection (Table.Columns.Count - 1, SelectedRow, extend);
Update ();
}
///
/// Moves or extends the selection to the first cell in the current row
///
/// true to extend the current selection (if any) instead of replacing
public void ChangeSelectionToStartOfRow (bool extend)
{
SetSelection (0, SelectedRow, extend);
Update ();
}
///
/// When is on, creates selection over all cells in the table (replacing any old selection regions)
///
public void SelectAll ()
{
if (TableIsNullOrInvisible () || !MultiSelect || Table.Rows.Count == 0)
return;
ClearMultiSelectedRegions (true);
// Create a single region over entire table, set the origin of the selection to the active cell so that a followup spread selection e.g. shift-right behaves properly
MultiSelectedRegions.Push (new TableSelection (new Point (SelectedColumn, SelectedRow), new Rect (0, 0, Table.Columns.Count, table.Rows.Count)));
Update ();
}
///
/// Returns all cells in any (if is enabled) and the selected cell
///
///
public IEnumerable GetAllSelectedCells ()
{
if (TableIsNullOrInvisible () || Table.Rows.Count == 0)
{
return Enumerable.Empty();
}
EnsureValidSelection ();
var toReturn = new HashSet();
// If there are one or more rectangular selections
if (MultiSelect && MultiSelectedRegions.Any ()) {
// Quiz any cells for whether they are selected. For performance we only need to check those between the top left and lower right vertex of selection regions
var yMin = MultiSelectedRegions.Min (r => r.Rect.Top);
var yMax = MultiSelectedRegions.Max (r => r.Rect.Bottom);
var xMin = FullRowSelect ? 0 : MultiSelectedRegions.Min (r => r.Rect.Left);
var xMax = FullRowSelect ? Table.Columns.Count : MultiSelectedRegions.Max (r => r.Rect.Right);
for (int y = yMin; y < yMax; y++) {
for (int x = xMin; x < xMax; x++) {
if (IsSelected (x, y)) {
toReturn.Add(new Point (x, y));
}
}
}
}
// if there are no region selections then it is just the active cell
// if we are selecting the full row
if (FullRowSelect) {
// all cells in active row are selected
for (int x = 0; x < Table.Columns.Count; x++) {
toReturn.Add(new Point (x, SelectedRow));
}
} else {
// Not full row select and no multi selections
toReturn.Add(new Point (SelectedColumn, SelectedRow));
}
return toReturn;
}
///
/// Returns a new rectangle between the two points with positive width/height regardless of relative positioning of the points. pt1 is always considered the point
///
/// Origin point for the selection in X
/// Origin point for the selection in Y
/// End point for the selection in X
/// End point for the selection in Y
/// True if selection is result of
///
private TableSelection CreateTableSelection (int pt1X, int pt1Y, int pt2X, int pt2Y, bool toggle = false)
{
var top = Math.Max (Math.Min (pt1Y, pt2Y), 0);
var bot = Math.Max (Math.Max (pt1Y, pt2Y), 0);
var left = Math.Max (Math.Min (pt1X, pt2X), 0);
var right = Math.Max (Math.Max (pt1X, pt2X), 0);
// Rect class is inclusive of Top Left but exclusive of Bottom Right so extend by 1
return new TableSelection (new Point (pt1X, pt1Y), new Rect (left, top, right - left + 1, bot - top + 1)) {
IsToggled = toggle
};
}
private void ToggleCurrentCellSelection ()
{
if (!MultiSelect) {
return;
}
var regions = GetMultiSelectedRegionsContaining(selectedColumn, selectedRow).ToArray();
var toggles = regions.Where(s=>s.IsToggled).ToArray ();
// Toggle it off
if (toggles.Any ()) {
var oldRegions = MultiSelectedRegions.ToArray ().Reverse ();
MultiSelectedRegions.Clear ();
foreach (var region in oldRegions) {
if (!toggles.Contains (region))
MultiSelectedRegions.Push (region);
}
} else {
// user is toggling selection within a rectangular
// select. So toggle the full region
if(regions.Any())
{
foreach(var r in regions)
{
r.IsToggled = true;
}
}
else{
// Toggle on a single cell selection
MultiSelectedRegions.Push (
CreateTableSelection (selectedColumn, SelectedRow, selectedColumn, selectedRow, true)
);
}
}
}
///
/// Returns a single point as a
///
///
///
///
private TableSelection CreateTableSelection (int x, int y)
{
return CreateTableSelection (x, y, x, y);
}
///
///
/// Returns true if the given cell is selected either because it is the active cell or part of a multi cell selection (e.g. ).
///
/// Returns if is .
///
///
///
///
public bool IsSelected (int col, int row)
{
if (!IsColumnVisible (col)) {
return false;
}
if(GetMultiSelectedRegionsContaining(col,row).Any())
{
return true;
}
return row == SelectedRow &&
(col == SelectedColumn || FullRowSelect);
}
private IEnumerable GetMultiSelectedRegionsContaining(int col, int row)
{
if(!MultiSelect)
{
return Enumerable.Empty();
}
if(FullRowSelect)
{
return MultiSelectedRegions.Where (r => r.Rect.Bottom > row && r.Rect.Top <= row);
}
else
{
return MultiSelectedRegions.Where (r => r.Rect.Contains (col, row));
}
}
///
/// Returns true if the given indexes a visible
/// column otherwise false. Returns false for indexes that are out of bounds.
///
///
///
private bool IsColumnVisible (int columnIndex)
{
// if the column index provided is out of bounds
if (columnIndex < 0 || columnIndex >= table.Columns.Count) {
return false;
}
return this.Style.GetColumnStyleIfAny (Table.Columns [columnIndex])?.Visible ?? true;
}
///
/// Positions the cursor in the area of the screen in which the start of the active cell is rendered. Calls base implementation if active cell is not visible due to scrolling or table is loaded etc
///
public override void PositionCursor ()
{
if (TableIsNullOrInvisible ()) {
base.PositionCursor ();
return;
}
var screenPoint = CellToScreen (SelectedColumn, SelectedRow);
if (screenPoint != null)
Move (screenPoint.Value.X, screenPoint.Value.Y);
}
///
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 &&
me.Flags != MouseFlags.WheeledLeft && me.Flags != MouseFlags.WheeledRight)
return false;
if (!HasFocus && CanFocus) {
SetFocus ();
}
if (TableIsNullOrInvisible ()) {
return false;
}
// Scroll wheel flags
switch (me.Flags) {
case MouseFlags.WheeledDown:
RowOffset++;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
return true;
case MouseFlags.WheeledUp:
RowOffset--;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
return true;
case MouseFlags.WheeledRight:
ColumnOffset++;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
return true;
case MouseFlags.WheeledLeft:
ColumnOffset--;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
return true;
}
if (me.Flags.HasFlag (MouseFlags.Button1Clicked)) {
if (scrollLeftPoint != null
&& scrollLeftPoint.Value.X == me.X
&& scrollLeftPoint.Value.Y == me.Y) {
ColumnOffset--;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
}
if (scrollRightPoint != null
&& scrollRightPoint.Value.X == me.X
&& scrollRightPoint.Value.Y == me.Y) {
ColumnOffset++;
EnsureValidScrollOffsets ();
SetNeedsDisplay ();
}
var hit = ScreenToCell (me.X, me.Y);
if (hit != null) {
if (MultiSelect && HasControlOrAlt (me)) {
UnionSelection (hit.Value.X, hit.Value.Y);
} else {
SetSelection (hit.Value.X, hit.Value.Y, me.Flags.HasFlag (MouseFlags.ButtonShift));
}
Update ();
}
}
// Double clicking a cell activates
if (me.Flags == MouseFlags.Button1DoubleClicked) {
var hit = ScreenToCell (me.X, me.Y);
if (hit != null) {
OnCellActivated (new CellActivatedEventArgs (Table, hit.Value.X, hit.Value.Y));
}
}
return false;
}
private bool HasControlOrAlt (MouseEvent me)
{
return me.Flags.HasFlag (MouseFlags.ButtonAlt) || me.Flags.HasFlag (MouseFlags.ButtonCtrl);
}
/// .
/// Returns the column and row of that corresponds to a given point
/// on the screen (relative to the control client area). Returns null if the point is
/// in the header, no table is loaded or outside the control bounds.
///
/// X offset from the top left of the control.
/// Y offset from the top left of the control.
/// Cell clicked or null.
public Point? ScreenToCell (int clientX, int clientY)
{
return ScreenToCell (clientX, clientY, out _);
}
///
/// X offset from the top left of the control.
/// Y offset from the top left of the control.
/// If the click is in a header this is the column clicked.
public Point? ScreenToCell (int clientX, int clientY, out DataColumn headerIfAny)
{
headerIfAny = null;
if (TableIsNullOrInvisible ())
return null;
var viewPort = CalculateViewport (Bounds);
var headerHeight = GetHeaderHeightIfAny ();
var col = viewPort.LastOrDefault (c => c.X <= clientX);
// Click is on the header section of rendered UI
if (clientY < headerHeight) {
headerIfAny = col?.Column;
return null;
}
var rowIdx = RowOffset - headerHeight + clientY;
// if click is off bottom of the rows don't give an
// invalid index back to user!
if (rowIdx >= Table.Rows.Count) {
return null;
}
if (col != null && rowIdx >= 0) {
return new Point (col.Column.Ordinal, rowIdx);
}
return null;
}
///
/// Returns the screen position (relative to the control client area) that the given cell is rendered or null if it is outside the current scroll area or no table is loaded
///
/// The index of the column you are looking for, use
/// The index of the row in that you are looking for
///
public Point? CellToScreen (int tableColumn, int tableRow)
{
if (TableIsNullOrInvisible ())
return null;
var viewPort = CalculateViewport (Bounds);
var headerHeight = GetHeaderHeightIfAny ();
var colHit = viewPort.FirstOrDefault (c => c.Column.Ordinal == tableColumn);
// current column is outside the scroll area
if (colHit == null)
return null;
// the cell is too far up above the current scroll area
if (RowOffset > tableRow)
return null;
// the cell is way down below the scroll area and off the screen
if (tableRow > RowOffset + (Bounds.Height - headerHeight))
return null;
return new Point (colHit.X, tableRow + headerHeight - RowOffset);
}
///
/// Updates the view to reflect changes to and to ( / ) etc
///
/// This always calls
public void Update ()
{
if (TableIsNullOrInvisible ()) {
SetNeedsDisplay ();
return;
}
EnsureValidScrollOffsets ();
EnsureValidSelection ();
EnsureSelectedCellIsVisible ();
SetNeedsDisplay ();
}
///
/// Updates and where they are outside the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if has not been set.
///
/// Changes will not be immediately visible in the display until you call
public void EnsureValidScrollOffsets ()
{
if (TableIsNullOrInvisible ()) {
return;
}
ColumnOffset = Math.Max (Math.Min (ColumnOffset, Table.Columns.Count - 1), 0);
RowOffset = Math.Max (Math.Min (RowOffset, Table.Rows.Count - 1), 0);
}
///
/// Updates , and where they are outside the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if has not been set.
///
/// Changes will not be immediately visible in the display until you call
public void EnsureValidSelection ()
{
if (TableIsNullOrInvisible ()) {
// Table doesn't exist, we should probably clear those selections
ClearMultiSelectedRegions (false);
return;
}
SelectedColumn = Math.Max (Math.Min (SelectedColumn, Table.Columns.Count - 1), 0);
SelectedRow = Math.Max (Math.Min (SelectedRow, Table.Rows.Count - 1), 0);
// If SelectedColumn is invisible move it to a visible one
SelectedColumn = GetNearestVisibleColumn (SelectedColumn, lookRight: true, true);
var oldRegions = MultiSelectedRegions.ToArray ().Reverse ();
MultiSelectedRegions.Clear ();
// evaluate
foreach (var region in oldRegions) {
// ignore regions entirely below current table state
if (region.Rect.Top >= Table.Rows.Count)
continue;
// ignore regions entirely too far right of table columns
if (region.Rect.Left >= Table.Columns.Count)
continue;
// ensure region's origin exists
region.Origin = new Point (
Math.Max (Math.Min (region.Origin.X, Table.Columns.Count - 1), 0),
Math.Max (Math.Min (region.Origin.Y, Table.Rows.Count - 1), 0));
// ensure regions do not go over edge of table bounds
region.Rect = Rect.FromLTRB (region.Rect.Left,
region.Rect.Top,
Math.Max (Math.Min (region.Rect.Right, Table.Columns.Count), 0),
Math.Max (Math.Min (region.Rect.Bottom, Table.Rows.Count), 0)
);
MultiSelectedRegions.Push (region);
}
}
///
/// Returns true if the is not set or all the
/// in the have an explicit
/// that marks them
/// .
///
///
private bool TableIsNullOrInvisible ()
{
return Table == null ||
Table.Columns.Count <= 0 ||
Table.Columns.Cast ().All (
c => (Style.GetColumnStyleIfAny (c)?.Visible ?? true) == false);
}
///
/// Returns unless the is false for
/// the indexed . If so then the index returned is nudged to the nearest visible
/// column.
///
/// Returns unchanged if it is invalid (e.g. out of bounds).
/// The input column index.
/// When nudging invisible selections look right first.
/// to look right, to look left.
/// If we cannot find anything visible when
/// looking in direction of then should we look in the opposite
/// direction instead? Use true if you want to push a selection to a valid index no matter what.
/// Use false if you are primarily interested in learning about directional column visibility.
private int GetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection)
{
if (TryGetNearestVisibleColumn (columnIndex, lookRight, allowBumpingInOppositeDirection, out var answer)) {
return answer;
}
return columnIndex;
}
private bool TryGetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection, out int idx)
{
// if the column index provided is out of bounds
if (columnIndex < 0 || columnIndex >= table.Columns.Count) {
idx = columnIndex;
return false;
}
// get the column visibility by index (if no style visible is true)
bool [] columnVisibility = Table.Columns.Cast ()
.Select (c => this.Style.GetColumnStyleIfAny (c)?.Visible ?? true)
.ToArray ();
// column is visible
if (columnVisibility [columnIndex]) {
idx = columnIndex;
return true;
}
int increment = lookRight ? 1 : -1;
// move in that direction
for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) {
// if we find a visible column
if (columnVisibility [i]) {
idx = i;
return true;
}
}
// Caller only wants to look in one direction and we did not find any
// visible columns in that direction
if (!allowBumpingInOppositeDirection) {
idx = columnIndex;
return false;
}
// Caller will let us look in the other direction so
// now look other way
increment = -increment;
for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) {
// if we find a visible column
if (columnVisibility [i]) {
idx = i;
return true;
}
}
// nothing seems to be visible so just return input index
idx = columnIndex;
return false;
}
///
/// Updates scroll offsets to ensure that the selected cell is visible. Has no effect if has not been set.
///
/// Changes will not be immediately visible in the display until you call
public void EnsureSelectedCellIsVisible ()
{
if (Table == null || Table.Columns.Count <= 0) {
return;
}
var columnsToRender = CalculateViewport (Bounds).ToArray ();
var headerHeight = GetHeaderHeightIfAny ();
//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)) {
if (Style.SmoothHorizontalScrolling) {
// Scroll right 1 column at a time until the users selected column is visible
while (SelectedColumn > columnsToRender.Max (r => r.Column.Ordinal)) {
ColumnOffset++;
columnsToRender = CalculateViewport (Bounds).ToArray ();
// if we are already scrolled to the last column then break
// this will prevent any theoretical infinite loop
if (ColumnOffset >= Table.Columns.Count - 1)
break;
}
} else {
ColumnOffset = SelectedColumn;
}
}
//if we have scrolled too far down
if (SelectedRow >= RowOffset + (Bounds.Height - headerHeight)) {
RowOffset = SelectedRow - (Bounds.Height - headerHeight) + 1;
}
//if we have scrolled too far up
if (SelectedRow < RowOffset) {
RowOffset = SelectedRow;
}
}
///
/// Invokes the event
///
protected virtual void OnSelectedCellChanged (SelectedCellChangedEventArgs args)
{
SelectedCellChanged?.Invoke (this,args);
}
///
/// Invokes the event
///
///
protected virtual void OnCellActivated (CellActivatedEventArgs args)
{
CellActivated?.Invoke (this, args);
}
///
/// Calculates which columns should be rendered given the in which to display and the
///
///
///
///
private IEnumerable CalculateViewport (Rect bounds, int padding = 1)
{
if (TableIsNullOrInvisible ()) {
return Enumerable.Empty ();
}
var toReturn = new List ();
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;
var lastColumn = Table.Columns.Cast ().Last ();
foreach (var col in Table.Columns.Cast ().Skip (ColumnOffset)) {
int startingIdxForCurrentHeader = usedSpace;
var colStyle = Style.GetColumnStyleIfAny (col);
int colWidth;
// if column is not being rendered
if (colStyle?.Visible == false) {
// do not add it to the returned columns
continue;
}
// is there enough space for this column (and it's data)?
colWidth = CalculateMaxCellWidth (col, rowsToRender, colStyle) + padding;
// there is not enough space for this columns
// visible content
if (usedSpace + colWidth > availableHorizontalSpace) {
bool showColumn = false;
// if this column accepts flexible width rendering and
// is therefore happy rendering into less space
if (colStyle != null && colStyle.MinAcceptableWidth > 0 &&
// is there enough space to meet the MinAcceptableWidth
(availableHorizontalSpace - usedSpace) >= colStyle.MinAcceptableWidth) {
// show column and use use whatever space is
// left for rendering it
showColumn = true;
colWidth = availableHorizontalSpace - usedSpace;
}
// If its the only column we are able to render then
// accept it anyway (that must be one massively wide column!)
if (first) {
showColumn = true;
}
// no special exceptions and we are out of space
// so stop accepting new columns for the render area
if (!showColumn)
break;
}
usedSpace += colWidth;
// required for if we end up here because first == true i.e. we have a single massive width (overspilling bounds) column to present
colWidth = Math.Min (availableHorizontalSpace, colWidth);
var isVeryLast = lastColumn == col;
// there is space
toReturn.Add(new ColumnToRender (col, startingIdxForCurrentHeader, colWidth, isVeryLast));
first = false;
}
if(Style.ExpandLastColumn)
{
var last = toReturn.Last ();
last.Width = Math.Max (last.Width, availableHorizontalSpace - last.X);
}
return toReturn;
}
private bool ShouldRenderHeaders ()
{
if (TableIsNullOrInvisible ())
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.Sum (c => Rune.ColumnWidth (c));
// 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).Sum (c => Rune.ColumnWidth (c)));
}
// 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 ();
}
///
/// Delegate for providing color to cells based on the value being rendered
///
/// Contains information about the cell for which color is needed
///
public delegate ColorScheme CellColorGetterDelegate (CellColorGetterArgs args);
///
/// Delegate for providing color for a whole row of a
///
///
///
public delegate ColorScheme RowColorGetterDelegate (RowColorGetterArgs args);
#region Nested Types
///
/// Describes how to render a given column in a including
/// and textual representation of cells (e.g. date formats)
///
/// See TableView Deep Dive for more information.
///
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