Add support for tables

This commit is contained in:
Patrik Svensson
2020-08-04 16:05:57 +02:00
committed by Patrik Svensson
parent aa34c145b9
commit a068fc68c3
19 changed files with 837 additions and 33 deletions

View File

@ -10,12 +10,14 @@ namespace Spectre.Console
{
private static readonly Lazy<IAnsiConsole> _console = new Lazy<IAnsiConsole>(() =>
{
return Create(new AnsiConsoleSettings
var console = Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Detect,
ColorSystem = ColorSystemSupport.Detect,
Out = System.Console.Out,
});
Created = true;
return console;
});
/// <summary>
@ -28,6 +30,8 @@ namespace Spectre.Console
/// </summary>
public static Capabilities Capabilities => Console.Capabilities;
internal static bool Created { get; private set; }
/// <summary>
/// Gets the buffer width of the console.
/// </summary>

View File

@ -16,16 +16,27 @@ namespace Spectre.Console
/// </summary>
public ColorSystem ColorSystem { get; }
internal Capabilities(bool supportsAnsi, ColorSystem colorSystem)
/// <summary>
/// Gets a value indicating whether or not
/// this is a legacy console (cmd.exe).
/// </summary>
/// <remarks>
/// Only relevant when running on Microsoft Windows.
/// </remarks>
public bool LegacyConsole { get; }
internal Capabilities(bool supportsAnsi, ColorSystem colorSystem, bool legacyConsole)
{
SupportsAnsi = supportsAnsi;
ColorSystem = colorSystem;
LegacyConsole = legacyConsole;
}
/// <inheritdoc/>
public override string ToString()
{
var supportsAnsi = SupportsAnsi ? "Yes" : "No";
var legacyConsole = LegacyConsole ? "Legacy" : "Modern";
var bits = ColorSystem switch
{
ColorSystem.NoColors => "1 bit",
@ -36,7 +47,7 @@ namespace Spectre.Console
_ => "?"
};
return $"ANSI={supportsAnsi}, Colors={ColorSystem} ({bits})";
return $"ANSI={supportsAnsi}, Colors={ColorSystem}, Kind={legacyConsole} ({bits})";
}
}
}

View File

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Spectre.Console.Composition
{
/// <summary>
/// Represents a border used by tables.
/// </summary>
public abstract class Border
{
private readonly Dictionary<BorderPart, char> _lookup;
private static readonly Dictionary<BorderKind, Border> _borders = new Dictionary<BorderKind, Border>
{
{ BorderKind.Ascii, new AsciiBorder() },
{ BorderKind.Square, new SquareBorder() },
};
/// <summary>
/// Initializes a new instance of the <see cref="Border"/> class.
/// </summary>
protected Border()
{
_lookup = Initialize();
}
/// <summary>
/// Gets a <see cref="Border"/> represented by the specified <see cref="BorderKind"/>.
/// </summary>
/// <param name="kind">The kind of border to get.</param>
/// <returns>A <see cref="Border"/> instance representing the specified <see cref="BorderKind"/>.</returns>
public static Border GetBorder(BorderKind kind)
{
if (!_borders.TryGetValue(kind, out var border))
{
throw new InvalidOperationException("Unknown border kind");
}
return border;
}
private Dictionary<BorderPart, char> Initialize()
{
var lookup = new Dictionary<BorderPart, char>();
foreach (BorderPart part in Enum.GetValues(typeof(BorderPart)))
{
lookup.Add(part, GetBoxPart(part));
}
return lookup;
}
/// <summary>
/// Gets the string representation of a specific border part.
/// </summary>
/// <param name="part">The part to get a string representation for.</param>
/// <param name="count">The number of repetitions.</param>
/// <returns>A string representation of the specified border part.</returns>
public string GetPart(BorderPart part, int count)
{
return new string(GetBoxPart(part), count);
}
/// <summary>
/// Gets the string representation of a specific border part.
/// </summary>
/// <param name="part">The part to get a string representation for.</param>
/// <returns>A string representation of the specified border part.</returns>
public string GetPart(BorderPart part)
{
return _lookup[part].ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Gets the character representing the specified border part.
/// </summary>
/// <param name="part">The part to get the character representation for.</param>
/// <returns>A character representation of the specified border part.</returns>
protected abstract char GetBoxPart(BorderPart part);
}
}

View File

@ -0,0 +1,18 @@
namespace Spectre.Console
{
/// <summary>
/// Represents different kinds of borders.
/// </summary>
public enum BorderKind
{
/// <summary>
/// A square border.
/// </summary>
Square = 0,
/// <summary>
/// An old school ASCII border.
/// </summary>
Ascii = 1,
}
}

View File

@ -0,0 +1,98 @@
namespace Spectre.Console.Composition
{
/// <summary>
/// Represents the different border parts.
/// </summary>
public enum BorderPart
{
/// <summary>
/// The top left part of a header.
/// </summary>
HeaderTopLeft,
/// <summary>
/// The top part of a header.
/// </summary>
HeaderTop,
/// <summary>
/// The top separator part of a header.
/// </summary>
HeaderTopSeparator,
/// <summary>
/// The top right part of a header.
/// </summary>
HeaderTopRight,
/// <summary>
/// The left part of a header.
/// </summary>
HeaderLeft,
/// <summary>
/// A header separator.
/// </summary>
HeaderSeparator,
/// <summary>
/// The right part of a header.
/// </summary>
HeaderRight,
/// <summary>
/// The bottom left part of a header.
/// </summary>
HeaderBottomLeft,
/// <summary>
/// The bottom part of a header.
/// </summary>
HeaderBottom,
/// <summary>
/// The bottom separator part of a header.
/// </summary>
HeaderBottomSeparator,
/// <summary>
/// The bottom right part of a header.
/// </summary>
HeaderBottomRight,
/// <summary>
/// The left part of a cell.
/// </summary>
CellLeft,
/// <summary>
/// A cell separator.
/// </summary>
CellSeparator,
/// <summary>
/// The right part of a cell.
/// </summary>
ColumnRight,
/// <summary>
/// The bottom left part of a footer.
/// </summary>
FooterBottomLeft,
/// <summary>
/// The bottom part of a footer.
/// </summary>
FooterBottom,
/// <summary>
/// The bottom separator part of a footer.
/// </summary>
FooterBottomSeparator,
/// <summary>
/// The bottom right part of a footer.
/// </summary>
FooterBottomRight,
}
}

View File

@ -0,0 +1,37 @@
using System;
namespace Spectre.Console.Composition
{
/// <summary>
/// Represents an old school ASCII border.
/// </summary>
public sealed class AsciiBorder : Border
{
/// <inheritdoc/>
protected override char GetBoxPart(BorderPart part)
{
return part switch
{
BorderPart.HeaderTopLeft => '+',
BorderPart.HeaderTop => '-',
BorderPart.HeaderTopSeparator => '-',
BorderPart.HeaderTopRight => '+',
BorderPart.HeaderLeft => '|',
BorderPart.HeaderSeparator => '|',
BorderPart.HeaderRight => '|',
BorderPart.HeaderBottomLeft => '|',
BorderPart.HeaderBottom => '-',
BorderPart.HeaderBottomSeparator => '+',
BorderPart.HeaderBottomRight => '|',
BorderPart.CellLeft => '|',
BorderPart.CellSeparator => '|',
BorderPart.ColumnRight => '|',
BorderPart.FooterBottomLeft => '+',
BorderPart.FooterBottom => '-',
BorderPart.FooterBottomSeparator => '-',
BorderPart.FooterBottomRight => '+',
_ => throw new InvalidOperationException("Unknown box part."),
};
}
}
}

View File

@ -0,0 +1,37 @@
using System;
namespace Spectre.Console.Composition
{
/// <summary>
/// Represents a square border.
/// </summary>
public sealed class SquareBorder : Border
{
/// <inheritdoc/>
protected override char GetBoxPart(BorderPart part)
{
return part switch
{
BorderPart.HeaderTopLeft => '┌',
BorderPart.HeaderTop => '─',
BorderPart.HeaderTopSeparator => '┬',
BorderPart.HeaderTopRight => '┐',
BorderPart.HeaderLeft => '│',
BorderPart.HeaderSeparator => '│',
BorderPart.HeaderRight => '│',
BorderPart.HeaderBottomLeft => '├',
BorderPart.HeaderBottom => '─',
BorderPart.HeaderBottomSeparator => '┼',
BorderPart.HeaderBottomRight => '┤',
BorderPart.CellLeft => '│',
BorderPart.CellSeparator => '│',
BorderPart.ColumnRight => '│',
BorderPart.FooterBottomLeft => '└',
BorderPart.FooterBottom => '─',
BorderPart.FooterBottomSeparator => '┴',
BorderPart.FooterBottomRight => '┘',
_ => throw new InvalidOperationException("Unknown box part."),
};
}
}
}

View File

@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Spectre.Console.Composition;
using Spectre.Console.Internal;
namespace Spectre.Console
{
/// <summary>
/// Represents a table.
/// </summary>
public sealed class Table : IRenderable
{
private readonly List<Text> _columns;
private readonly List<List<Text>> _rows;
private readonly Border _border;
/// <summary>
/// Initializes a new instance of the <see cref="Table"/> class.
/// </summary>
/// <param name="border">The border to use.</param>
public Table(BorderKind border = BorderKind.Square)
{
_columns = new List<Text>();
_rows = new List<List<Text>>();
_border = Border.GetBorder(border);
}
/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="column">The column to add.</param>
public void AddColumn(string column)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
_columns.Add(Text.New(column));
}
/// <summary>
/// Adds multiple columns to the table.
/// </summary>
/// <param name="columns">The columns to add.</param>
public void AddColumns(params string[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
_columns.AddRange(columns.Select(column => Text.New(column)));
}
/// <summary>
/// Adds a row to the table.
/// </summary>
/// <param name="columns">The row columns to add.</param>
public void AddRow(params string[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
if (columns.Length < _columns.Count)
{
throw new InvalidOperationException("The number of row columns are less than the number of table columns.");
}
if (columns.Length > _columns.Count)
{
throw new InvalidOperationException("The number of row columns are greater than the number of table columns.");
}
_rows.Add(columns.Select(column => Text.New(column)).ToList());
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
{
// Calculate the max width for each column.
var maxColumnWidth = (maxWidth - (2 + (_columns.Count * 2) + (_columns.Count - 1))) / _columns.Count;
var columnWidths = _columns.Select(c => c.Measure(encoding, maxColumnWidth)).ToArray();
for (var rowIndex = 0; rowIndex < _rows.Count; rowIndex++)
{
for (var columnIndex = 0; columnIndex < _rows[rowIndex].Count; columnIndex++)
{
var columnWidth = _rows[rowIndex][columnIndex].Measure(encoding, maxColumnWidth);
if (columnWidth > columnWidths[columnIndex])
{
columnWidths[columnIndex] = columnWidth;
}
}
}
// We now know the max width of each column, so let's recalculate the width.
return columnWidths.Sum() + 2 + (_columns.Count * 2) + (_columns.Count - 1);
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
// Calculate the max width for each column.
var maxColumnWidth = (width - (2 + (_columns.Count * 2) + (_columns.Count - 1))) / _columns.Count;
var columnWidths = _columns.Select(c => c.Measure(encoding, maxColumnWidth)).ToArray();
for (var rowIndex = 0; rowIndex < _rows.Count; rowIndex++)
{
for (var columnIndex = 0; columnIndex < _rows[rowIndex].Count; columnIndex++)
{
var columnWidth = _rows[rowIndex][columnIndex].Measure(encoding, maxColumnWidth);
if (columnWidth > columnWidths[columnIndex])
{
columnWidths[columnIndex] = columnWidth;
}
}
}
// We now know the max width of each column, so let's recalculate the width.
width = columnWidths.Sum() + 2 + (_columns.Count * 2) + (_columns.Count - 1);
// Create the rows.
var rows = new List<List<Text>>();
rows.Add(new List<Text>(_columns));
rows.AddRange(_rows);
// Iterate all rows.
var result = new List<Segment>();
foreach (var (index, firstRow, lastRow, row) in rows.Enumerate())
{
var cellHeight = 1;
// Get the list of cells for the row.
var cells = new List<List<SegmentLine>>();
foreach (var (rowWidth, cell) in columnWidths.Zip(row, (f, s) => (f, s)))
{
var lines = Segment.SplitLines(cell.Render(encoding, rowWidth));
cellHeight = Math.Max(cellHeight, lines.Count);
cells.Add(lines);
}
if (firstRow)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTopLeft)));
foreach (var (columnIndex, _, lastColumn, columnWidth) in columnWidths.Enumerate())
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTop))); // Left padding
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTop, columnWidth)));
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTop))); // Right padding
if (!lastColumn)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTopSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTopRight)));
result.Add(Segment.LineBreak());
}
// Iterate through each cell row
foreach (var cellRowIndex in Enumerable.Range(0, cellHeight))
{
// Make cells the same shape
MakeSameHeight(cellHeight, cells);
var w00t = cells.Enumerate().ToArray();
foreach (var (cellIndex, firstCell, lastCell, cell) in w00t)
{
if (firstCell)
{
result.Add(new Segment(_border.GetPart(BorderPart.CellLeft)));
}
result.Add(new Segment(" "));
result.AddRange(cell[cellRowIndex]);
// Pad cell right
var length = cell[cellRowIndex].Sum(segment => segment.CellLength(encoding));
if (length < columnWidths[cellIndex])
{
result.Add(new Segment(new string(' ', columnWidths[cellIndex] - length)));
}
result.Add(new Segment(" "));
if (lastCell)
{
// Separator
result.Add(new Segment(_border.GetPart(BorderPart.ColumnRight)));
}
else
{
// Separator
result.Add(new Segment(_border.GetPart(BorderPart.CellSeparator)));
}
}
result.Add(Segment.LineBreak());
}
if (firstRow)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottomLeft)));
foreach (var (columnIndex, first, lastColumn, columnWidth) in columnWidths.Enumerate())
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottom))); // Left padding
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottom, columnWidth)));
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottom))); // Right padding
if (!lastColumn)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottomSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottomRight)));
result.Add(Segment.LineBreak());
}
if (lastRow)
{
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottomLeft)));
foreach (var (columnIndex, first, lastColumn, columnWidth) in columnWidths.Enumerate())
{
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottom)));
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottom, columnWidth)));
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottom)));
if (!lastColumn)
{
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottomSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottomRight)));
result.Add(Segment.LineBreak());
}
}
return result;
}
private static void MakeSameHeight(int cellHeight, List<List<SegmentLine>> cells)
{
foreach (var cell in cells)
{
if (cell.Count < cellHeight)
{
while (cell.Count != cellHeight)
{
cell.Add(new SegmentLine());
}
}
}
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
@ -12,6 +13,7 @@ namespace Spectre.Console
/// Represents text with color and decorations.
/// </summary>
[SuppressMessage("Naming", "CA1724:Type names should not match namespaces")]
[DebuggerDisplay("{_text,nq}")]
public sealed class Text : IRenderable
{
private readonly List<Span> _spans;
@ -98,15 +100,24 @@ namespace Spectre.Console
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
{
var lines = _text.SplitLines();
return lines.Max(x => x.CellLength(encoding));
var lines = Segment.SplitLines(Render(encoding, maxWidth));
if (lines.Count == 0)
{
return 0;
}
return lines.Max(x => x.Length);
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
var result = new List<Segment>();
if (string.IsNullOrWhiteSpace(_text))
{
return Array.Empty<Segment>();
}
var result = new List<Segment>();
var segments = SplitLineBreaks(CreateSegments());
foreach (var (_, _, last, line) in Segment.SplitLines(segments, width).Enumerate())

View File

@ -32,12 +32,12 @@ namespace Spectre.Console.Internal
new Regex("bvterm"), // Bitvise SSH Client
};
public static bool Detect(bool upgrade)
public static (bool SupportsAnsi, bool LegacyConsole) Detect(bool upgrade)
{
// Github action doesn't setup a correct PTY but supports ANSI.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_ACTION")))
{
return true;
return (true, false);
}
// Running on Windows?
@ -47,10 +47,11 @@ namespace Spectre.Console.Internal
var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI");
if (!string.IsNullOrEmpty(conEmu) && conEmu.Equals("On", StringComparison.OrdinalIgnoreCase))
{
return true;
return (true, false);
}
return Windows.SupportsAnsi(upgrade);
var supportsAnsi = Windows.SupportsAnsi(upgrade, out var legacyConsole);
return (supportsAnsi, legacyConsole);
}
// Check if the terminal is of type ANSI/VT100/xterm compatible.
@ -59,11 +60,11 @@ namespace Spectre.Console.Internal
{
if (_regexes.Any(regex => regex.IsMatch(term)))
{
return true;
return (true, false);
}
}
return false;
return (false, true);
}
[SuppressMessage("Design", "CA1060:Move pinvokes to native methods class")]
@ -91,8 +92,10 @@ namespace Spectre.Console.Internal
public static extern uint GetLastError();
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public static bool SupportsAnsi(bool upgrade)
public static bool SupportsAnsi(bool upgrade, out bool isLegacy)
{
isLegacy = false;
try
{
var @out = GetStdHandle(STD_OUTPUT_HANDLE);
@ -104,6 +107,8 @@ namespace Spectre.Console.Internal
if ((mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0)
{
isLegacy = true;
if (!upgrade)
{
return false;

View File

@ -41,12 +41,12 @@ namespace Spectre.Console.Internal
}
}
public AnsiConsoleRenderer(TextWriter @out, ColorSystem system)
public AnsiConsoleRenderer(TextWriter @out, ColorSystem system, bool legacyConsole)
{
_out = @out ?? throw new ArgumentNullException(nameof(@out));
_system = system;
Capabilities = new Capabilities(true, system);
Capabilities = new Capabilities(true, system, legacyConsole);
Encoding = @out.IsStandardOut() ? System.Console.OutputEncoding : Encoding.UTF8;
Foreground = Color.Default;
Background = Color.Default;

View File

@ -1,4 +1,5 @@
using System;
using System.Runtime.InteropServices;
namespace Spectre.Console.Internal
{
@ -13,9 +14,41 @@ namespace Spectre.Console.Internal
var buffer = settings.Out ?? System.Console.Out;
var supportsAnsi = settings.Ansi == AnsiSupport.Detect
? AnsiDetector.Detect(true)
: settings.Ansi == AnsiSupport.Yes;
var supportsAnsi = settings.Ansi == AnsiSupport.Yes;
var legacyConsole = false;
if (settings.Ansi == AnsiSupport.Detect)
{
(supportsAnsi, legacyConsole) = AnsiDetector.Detect(true);
// Check whether or not this is a legacy console from the existing instance (if any).
// We need to do this because once we upgrade the console to support ENABLE_VIRTUAL_TERMINAL_PROCESSING
// on Windows, there is no way of detecting whether or not we're running on a legacy console or not.
if (AnsiConsole.Created && !legacyConsole && buffer.IsStandardOut() && AnsiConsole.Capabilities.LegacyConsole)
{
legacyConsole = AnsiConsole.Capabilities.LegacyConsole;
}
}
else
{
if (buffer.IsStandardOut())
{
// Are we running on Windows?
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Not the first console we're creating?
if (AnsiConsole.Created)
{
legacyConsole = AnsiConsole.Capabilities.LegacyConsole;
}
else
{
// Try detecting whether or not this
(_, legacyConsole) = AnsiDetector.Detect(false);
}
}
}
}
var colorSystem = settings.ColorSystem == ColorSystemSupport.Detect
? ColorSystemDetector.Detect(supportsAnsi)
@ -23,13 +56,13 @@ namespace Spectre.Console.Internal
if (supportsAnsi)
{
return new AnsiConsoleRenderer(buffer, colorSystem)
return new AnsiConsoleRenderer(buffer, colorSystem, legacyConsole)
{
Decoration = Decoration.None,
};
}
return new FallbackConsoleRenderer(buffer, colorSystem);
return new FallbackConsoleRenderer(buffer, colorSystem, legacyConsole);
}
}
}

View File

@ -85,7 +85,7 @@ namespace Spectre.Console.Internal
}
}
public FallbackConsoleRenderer(TextWriter @out, ColorSystem system)
public FallbackConsoleRenderer(TextWriter @out, ColorSystem system, bool legacyConsole)
{
_out = @out;
_system = system;
@ -105,7 +105,7 @@ namespace Spectre.Console.Internal
Encoding = Encoding.UTF8;
}
Capabilities = new Capabilities(false, _system);
Capabilities = new Capabilities(false, _system, legacyConsole);
}
public void Write(string text)

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Spectre.Console.Internal
{
@ -35,7 +36,7 @@ namespace Spectre.Console.Internal
else if (token.Kind == MarkupTokenKind.Text)
{
// Get the effecive style.
var effectiveStyle = style.Combine(stack);
var effectiveStyle = style.Combine(stack.Reverse());
result.Append(token.Value, effectiveStyle);
}
else

View File

@ -18,12 +18,7 @@ namespace Spectre.Console.Internal
public static bool TryParse(string text, out Style style)
{
style = Parse(text, out var error);
if (error != null)
{
return false;
}
return true;
return error == null;
}
private static Style Parse(string text, out string error)