Clean up table rendering a bit

This commit is contained in:
Patrik Svensson 2021-01-02 09:05:49 +01:00 committed by Patrik Svensson
parent c6210f75ca
commit 179e243214
11 changed files with 683 additions and 501 deletions

View File

@ -0,0 +1,18 @@
<ProjectConfiguration>
<Settings>
<IgnoredTests>
<NamedTestSelector>
<TestName>Spectre.Console.Tests.Unit.Cli.CommandAppTests+Parsing+UnknownCommand.Should_Return_Correct_Text_With_Suggestion_And_No_Arguments_When_Root_Command_Is_Unknown_And_Distance_Is_Small</TestName>
</NamedTestSelector>
<NamedTestSelector>
<TestName>Spectre.Console.Tests.Unit.Cli.CommandAppTests+Parsing+UnknownCommand.Should_Return_Correct_Text_With_Suggestion_When_Command_Followed_By_Argument_Is_Unknown_And_Distance_Is_Small</TestName>
</NamedTestSelector>
<NamedTestSelector>
<TestName>Spectre.Console.Tests.Unit.Cli.CommandAppTests+Parsing+UnknownCommand.Should_Return_Correct_Text_With_Suggestion_When_Root_Command_After_Argument_Is_Unknown_And_Distance_Is_Small</TestName>
</NamedTestSelector>
<NamedTestSelector>
<TestName>Spectre.Console.Tests.Unit.Cli.CommandAppTests+Parsing+UnknownCommand.Should_Return_Correct_Text_With_Suggestion_When_Root_Command_Followed_By_Argument_Is_Unknown_And_Distance_Is_Small</TestName>
</NamedTestSelector>
</IgnoredTests>
</Settings>
</ProjectConfiguration>

View File

@ -6,6 +6,23 @@ namespace Spectre.Console.Internal
{
internal static class EnumerableExtensions
{
public static int IndexOf<T>(this IEnumerable<T> source, T item)
where T : class
{
var index = 0;
foreach (var candidate in source)
{
if (candidate == item)
{
return index;
}
index++;
}
return -1;
}
public static int GetCount<T>(this IEnumerable<T> source)
{
if (source is IList<T> list)

View File

@ -1,501 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable table.
/// </summary>
public sealed class Table : Renderable, IHasTableBorder, IExpandable, IAlignable
{
private const int EdgeCount = 2;
private readonly List<TableColumn> _columns;
private readonly List<TableRow> _rows;
private static readonly Style _defaultHeadingStyle = new Style(Color.Silver);
private static readonly Style _defaultCaptionStyle = new Style(Color.Grey);
/// <summary>
/// Gets the table columns.
/// </summary>
public IReadOnlyList<TableColumn> Columns => _columns;
/// <summary>
/// Gets the table rows.
/// </summary>
public IReadOnlyList<TableRow> Rows => _rows;
/// <inheritdoc/>
public TableBorder Border { get; set; } = TableBorder.Square;
/// <inheritdoc/>
public Style? BorderStyle { get; set; }
/// <inheritdoc/>
public bool UseSafeBorder { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not table headers should be shown.
/// </summary>
public bool ShowHeaders { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not table footers should be shown.
/// </summary>
public bool ShowFooters { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not the table should
/// fit the available space. If <c>false</c>, the table width will be
/// auto calculated. Defaults to <c>false</c>.
/// </summary>
public bool Expand { get; set; }
/// <summary>
/// Gets or sets the width of the table.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets the table title.
/// </summary>
public TableTitle? Title { get; set; }
/// <summary>
/// Gets or sets the table footnote.
/// </summary>
public TableTitle? Caption { get; set; }
/// <inheritdoc/>
public Justify? Alignment { get; set; }
// Whether this is a grid or not.
internal bool IsGrid { get; set; }
// Whether or not the most right cell should be padded.
// This is almost always the case, unless we're rendering
// a grid without explicit padding in the last cell.
internal bool PadRightCell { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="Table"/> class.
/// </summary>
public Table()
{
_columns = new List<TableColumn>();
_rows = new List<TableRow>();
}
/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="column">The column to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public Table AddColumn(TableColumn column)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (_rows.Count > 0)
{
throw new InvalidOperationException("Cannot add new columns to table with existing rows.");
}
_columns.Add(column);
return this;
}
/// <summary>
/// Adds a row to the table.
/// </summary>
/// <param name="columns">The row columns to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public Table AddRow(IEnumerable<IRenderable> columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
var rowColumnCount = columns.GetCount();
if (rowColumnCount > _columns.Count)
{
throw new InvalidOperationException("The number of row columns are greater than the number of table columns.");
}
_rows.Add(new TableRow(columns));
// Need to add missing columns?
if (rowColumnCount < _columns.Count)
{
var diff = _columns.Count - rowColumnCount;
Enumerable.Range(0, diff).ForEach(_ => _rows.Last().Add(Text.Empty));
}
return this;
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (Width != null)
{
maxWidth = Math.Min(Width.Value, maxWidth);
}
maxWidth -= GetExtraWidth(includePadding: true);
var measurements = _columns.Select(column => MeasureColumn(column, context, maxWidth)).ToList();
var min = measurements.Sum(x => x.Min) + GetExtraWidth(includePadding: true);
var max = Width ?? measurements.Sum(x => x.Max) + GetExtraWidth(includePadding: true);
return new Measurement(min, max);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var border = Border.GetSafeBorder((context.LegacyConsole || !context.Unicode) && UseSafeBorder);
var borderStyle = BorderStyle ?? Style.Plain;
var tableWidth = maxWidth;
var actualMaxWidth = maxWidth;
var showBorder = Border.Visible;
var hideBorder = !Border.Visible;
var hasRows = _rows.Count > 0;
var hasFooters = _columns.Any(c => c.Footer != null);
if (Width != null)
{
maxWidth = Math.Min(Width.Value, maxWidth);
}
maxWidth -= GetExtraWidth(includePadding: true);
// Calculate the column and table widths
var columnWidths = CalculateColumnWidths(context, maxWidth);
// Update the table width.
tableWidth = columnWidths.Sum() + GetExtraWidth(includePadding: true);
if (tableWidth <= 0 || tableWidth > actualMaxWidth || columnWidths.Any(c => c <= 0))
{
return new List<Segment>(new[] { new Segment("…", BorderStyle ?? Style.Plain) });
}
var rows = new List<TableRow>();
if (ShowHeaders)
{
// Add columns to top of rows
rows.Add(new TableRow(new List<IRenderable>(_columns.Select(c => c.Header))));
}
// Add rows.
rows.AddRange(_rows);
if (hasFooters)
{
rows.Add(new TableRow(new List<IRenderable>(_columns.Select(c => c.Footer ?? Text.Empty))));
}
var result = new List<Segment>();
result.AddRange(RenderAnnotation(context, Title, actualMaxWidth, tableWidth, _defaultHeadingStyle));
// Iterate all rows
foreach (var (index, firstRow, lastRow, row) in rows.Enumerate())
{
var cellHeight = 1;
// Get the list of cells for the row and calculate the cell height
var cells = new List<List<SegmentLine>>();
foreach (var (columnIndex, _, _, (rowWidth, cell)) in columnWidths.Zip(row).Enumerate())
{
var justification = _columns[columnIndex].Alignment;
var childContext = context.WithJustification(justification);
var lines = Segment.SplitLines(context, cell.Render(childContext, rowWidth));
cellHeight = Math.Max(cellHeight, lines.Count);
cells.Add(lines);
}
// Show top of header?
if (firstRow && showBorder)
{
var separator = Aligner.Align(context, border.GetColumnRow(TablePart.Top, columnWidths, _columns), Alignment, actualMaxWidth);
result.Add(new Segment(separator, borderStyle));
result.Add(Segment.LineBreak);
}
// Show footer separator?
if (ShowFooters && lastRow && showBorder && hasFooters)
{
var textBorder = border.GetColumnRow(TablePart.FooterSeparator, columnWidths, _columns);
if (!string.IsNullOrEmpty(textBorder))
{
var separator = Aligner.Align(context, textBorder, Alignment, actualMaxWidth);
result.Add(new Segment(separator, borderStyle));
result.Add(Segment.LineBreak);
}
}
// Make cells the same shape
cells = Segment.MakeSameHeight(cellHeight, cells);
// Iterate through each cell row
foreach (var cellRowIndex in Enumerable.Range(0, cellHeight))
{
var rowResult = new List<Segment>();
foreach (var (cellIndex, firstCell, lastCell, cell) in cells.Enumerate())
{
if (firstCell && showBorder)
{
// Show left column edge
var part = firstRow && ShowHeaders ? TableBorderPart.HeaderLeft : TableBorderPart.CellLeft;
rowResult.Add(new Segment(border.GetPart(part), borderStyle));
}
// Pad column on left side.
if (showBorder || IsGrid)
{
var leftPadding = _columns[cellIndex].Padding.GetLeftSafe();
if (leftPadding > 0)
{
rowResult.Add(new Segment(new string(' ', leftPadding)));
}
}
// Add content
rowResult.AddRange(cell[cellRowIndex]);
// Pad cell content right
var length = cell[cellRowIndex].Sum(segment => segment.CellCount(context));
if (length < columnWidths[cellIndex])
{
rowResult.Add(new Segment(new string(' ', columnWidths[cellIndex] - length)));
}
// Pad column on the right side
if (showBorder || (hideBorder && !lastCell) || (hideBorder && lastCell && IsGrid && PadRightCell))
{
var rightPadding = _columns[cellIndex].Padding.GetRightSafe();
if (rightPadding > 0)
{
rowResult.Add(new Segment(new string(' ', rightPadding)));
}
}
if (lastCell && showBorder)
{
// Add right column edge
var part = firstRow && ShowHeaders ? TableBorderPart.HeaderRight : TableBorderPart.CellRight;
rowResult.Add(new Segment(border.GetPart(part), borderStyle));
}
else if (showBorder)
{
// Add column separator
var part = firstRow && ShowHeaders ? TableBorderPart.HeaderSeparator : TableBorderPart.CellSeparator;
rowResult.Add(new Segment(border.GetPart(part), borderStyle));
}
}
// Align the row result.
Aligner.Align(context, rowResult, Alignment, actualMaxWidth);
// Is the row larger than the allowed max width?
if (Segment.CellCount(context, rowResult) > actualMaxWidth)
{
result.AddRange(Segment.Truncate(context, rowResult, actualMaxWidth));
}
else
{
result.AddRange(rowResult);
}
result.Add(Segment.LineBreak);
}
// Show header separator?
if (firstRow && showBorder && ShowHeaders && hasRows)
{
var separator = Aligner.Align(context, border.GetColumnRow(TablePart.HeaderSeparator, columnWidths, _columns), Alignment, actualMaxWidth);
result.Add(new Segment(separator, borderStyle));
result.Add(Segment.LineBreak);
}
// Show bottom of footer?
if (lastRow && showBorder)
{
var separator = Aligner.Align(context, border.GetColumnRow(TablePart.Bottom, columnWidths, _columns), Alignment, actualMaxWidth);
result.Add(new Segment(separator, borderStyle));
result.Add(Segment.LineBreak);
}
}
result.AddRange(RenderAnnotation(context, Caption, actualMaxWidth, tableWidth, _defaultCaptionStyle));
return result;
}
// Calculate the widths of each column, including padding, not including borders.
// Ported from Rich by Will McGugan, licensed under MIT.
// https://github.com/willmcgugan/rich/blob/527475837ebbfc427530b3ee0d4d0741d2d0fc6d/rich/table.py#L394
private List<int> CalculateColumnWidths(RenderContext options, int maxWidth)
{
var width_ranges = _columns.Select(column => MeasureColumn(column, options, maxWidth)).ToArray();
var widths = width_ranges.Select(range => range.Max).ToList();
var tableWidth = widths.Sum();
if (tableWidth > maxWidth)
{
var wrappable = _columns.Select(c => !c.NoWrap).ToList();
widths = CollapseWidths(widths, wrappable, maxWidth);
tableWidth = widths.Sum();
// last resort, reduce columns evenly
if (tableWidth > maxWidth)
{
var excessWidth = tableWidth - maxWidth;
widths = Ratio.Reduce(excessWidth, widths.Select(_ => 1).ToList(), widths, widths);
tableWidth = widths.Sum();
}
}
if (tableWidth < maxWidth && ShouldExpand())
{
var padWidths = Ratio.Distribute(maxWidth - tableWidth, widths);
widths = widths.Zip(padWidths, (a, b) => (a, b)).Select(f => f.a + f.b).ToList();
}
return widths;
}
// Reduce widths so that the total is less or equal to the max width.
// Ported from Rich by Will McGugan, licensed under MIT.
// https://github.com/willmcgugan/rich/blob/527475837ebbfc427530b3ee0d4d0741d2d0fc6d/rich/table.py#L442
private static List<int> CollapseWidths(List<int> widths, List<bool> wrappable, int maxWidth)
{
var totalWidth = widths.Sum();
var excessWidth = totalWidth - maxWidth;
if (wrappable.AnyTrue())
{
while (totalWidth != 0 && excessWidth > 0)
{
var maxColumn = widths.Zip(wrappable, (first, second) => (width: first, allowWrap: second))
.Where(x => x.allowWrap)
.Max(x => x.width);
var secondMaxColumn = widths.Zip(wrappable, (width, allowWrap) => allowWrap && width != maxColumn ? width : 1).Max();
var columnDifference = maxColumn - secondMaxColumn;
var ratios = widths.Zip(wrappable, (width, allowWrap) => width == maxColumn && allowWrap ? 1 : 0).ToList();
if (!ratios.Any(x => x != 0) || columnDifference == 0)
{
break;
}
var maxReduce = widths.Select(_ => Math.Min(excessWidth, columnDifference)).ToList();
widths = Ratio.Reduce(excessWidth, ratios, maxReduce, widths);
totalWidth = widths.Sum();
excessWidth = totalWidth - maxWidth;
}
}
return widths;
}
private IEnumerable<Segment> RenderAnnotation(
RenderContext context, TableTitle? header,
int maxWidth, int tableWidth, Style defaultStyle)
{
if (header == null)
{
return Array.Empty<Segment>();
}
var paragraph = new Markup(header.Text.Capitalize(), header.Style ?? defaultStyle)
.Alignment(Justify.Center)
.Overflow(Overflow.Ellipsis);
var items = new List<Segment>();
items.AddRange(((IRenderable)paragraph).Render(context, tableWidth));
// Align over the whole buffer area
Aligner.Align(context, items, Alignment, maxWidth);
items.Add(Segment.LineBreak);
return items;
}
private (int Min, int Max) MeasureColumn(TableColumn column, RenderContext options, int maxWidth)
{
// Predetermined width?
if (column.Width != null)
{
return (column.Width.Value, column.Width.Value);
}
var columnIndex = _columns.IndexOf(column);
var rows = _rows.Select(row => row[columnIndex]);
var minWidths = new List<int>();
var maxWidths = new List<int>();
// Include columns (both header and footer) in measurement
var headerMeasure = column.Header.Measure(options, maxWidth);
var footerMeasure = column.Footer?.Measure(options, maxWidth) ?? headerMeasure;
minWidths.Add(Math.Min(headerMeasure.Min, footerMeasure.Min));
maxWidths.Add(Math.Max(headerMeasure.Max, footerMeasure.Max));
foreach (var row in rows)
{
var rowMeasure = row.Measure(options, maxWidth);
minWidths.Add(rowMeasure.Min);
maxWidths.Add(rowMeasure.Max);
}
var padding = column.Padding?.GetWidth() ?? 0;
return (minWidths.Count > 0 ? minWidths.Max() : padding,
maxWidths.Count > 0 ? maxWidths.Max() : maxWidth);
}
private int GetExtraWidth(bool includePadding)
{
var hideBorder = !Border.Visible;
var separators = hideBorder ? 0 : _columns.Count - 1;
var edges = hideBorder ? 0 : EdgeCount;
var padding = includePadding ? _columns.Select(x => x.Padding?.GetWidth() ?? 0).Sum() : 0;
if (!PadRightCell)
{
padding -= _columns.Last().Padding.GetRightSafe();
}
return separators + edges + padding;
}
private bool ShouldExpand()
{
return Expand || Width != null;
}
}
}

View File

@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable table.
/// </summary>
public sealed class Table : Renderable, IHasTableBorder, IExpandable, IAlignable
{
private readonly List<TableColumn> _columns;
private readonly List<TableRow> _rows;
/// <summary>
/// Gets the table columns.
/// </summary>
public IReadOnlyList<TableColumn> Columns => _columns;
/// <summary>
/// Gets the table rows.
/// </summary>
public IReadOnlyList<TableRow> Rows => _rows;
/// <inheritdoc/>
public TableBorder Border { get; set; } = TableBorder.Square;
/// <inheritdoc/>
public Style? BorderStyle { get; set; }
/// <inheritdoc/>
public bool UseSafeBorder { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not table headers should be shown.
/// </summary>
public bool ShowHeaders { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not table footers should be shown.
/// </summary>
public bool ShowFooters { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not the table should
/// fit the available space. If <c>false</c>, the table width will be
/// auto calculated. Defaults to <c>false</c>.
/// </summary>
public bool Expand { get; set; }
/// <summary>
/// Gets or sets the width of the table.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets the table title.
/// </summary>
public TableTitle? Title { get; set; }
/// <summary>
/// Gets or sets the table footnote.
/// </summary>
public TableTitle? Caption { get; set; }
/// <inheritdoc/>
public Justify? Alignment { get; set; }
// Whether this is a grid or not.
internal bool IsGrid { get; set; }
// Whether or not the most right cell should be padded.
// This is almost always the case, unless we're rendering
// a grid without explicit padding in the last cell.
internal bool PadRightCell { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="Table"/> class.
/// </summary>
public Table()
{
_columns = new List<TableColumn>();
_rows = new List<TableRow>();
}
/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="column">The column to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public Table AddColumn(TableColumn column)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (_rows.Count > 0)
{
throw new InvalidOperationException("Cannot add new columns to table with existing rows.");
}
_columns.Add(column);
return this;
}
/// <summary>
/// Adds a row to the table.
/// </summary>
/// <param name="columns">The row columns to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public Table AddRow(IEnumerable<IRenderable> columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
var rowColumnCount = columns.GetCount();
if (rowColumnCount > _columns.Count)
{
throw new InvalidOperationException("The number of row columns are greater than the number of table columns.");
}
_rows.Add(new TableRow(columns));
// Need to add missing columns?
if (rowColumnCount < _columns.Count)
{
var diff = _columns.Count - rowColumnCount;
Enumerable.Range(0, diff).ForEach(_ => _rows.Last().Add(Text.Empty));
}
return this;
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var measurer = new TableMeasurer(this, context);
// Calculate the total cell width
var totalCellWidth = measurer.CalculateTotalCellWidth(maxWidth);
// Calculate the minimum and maximum table width
var measurements = _columns.Select(column => measurer.MeasureColumn(column, totalCellWidth));
var minTableWidth = measurements.Sum(x => x.Min) + measurer.GetNonColumnWidth();
var maxTableWidth = Width ?? measurements.Sum(x => x.Max) + measurer.GetNonColumnWidth();
return new Measurement(minTableWidth, maxTableWidth);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var measurer = new TableMeasurer(this, context);
// Calculate the column and table width
var totalCellWidth = measurer.CalculateTotalCellWidth(maxWidth);
var columnWidths = measurer.CalculateColumnWidths(totalCellWidth);
var tableWidth = columnWidths.Sum() + measurer.GetNonColumnWidth();
// Get the rows to render
var rows = GetRenderableRows();
// Render the table
return TableRenderer.Render(
new TableRendererContext(this, context, rows, tableWidth, maxWidth),
columnWidths);
}
private List<TableRow> GetRenderableRows()
{
var rows = new List<TableRow>();
// Show headers?
if (ShowHeaders)
{
rows.Add(TableRow.Header(_columns.Select(c => c.Header)));
}
// Add rows
rows.AddRange(_rows);
// Show footers?
if (ShowFooters && _columns.Any(c => c.Footer != null))
{
rows.Add(TableRow.Footer(_columns.Select(c => c.Footer ?? Text.Empty)));
}
return rows;
}
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal abstract class TableAccessor
{
private readonly Table _table;
public RenderContext Options { get; }
public IReadOnlyList<TableColumn> Columns => _table.Columns;
public virtual IReadOnlyList<TableRow> Rows => _table.Rows;
public bool Expand => _table.Expand || _table.Width != null;
protected TableAccessor(Table table, RenderContext options)
{
_table = table ?? throw new ArgumentNullException(nameof(table));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
}
}

View File

@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class TableMeasurer : TableAccessor
{
private const int EdgeCount = 2;
private readonly int? _explicitWidth;
private readonly TableBorder _border;
private readonly bool _padRightCell;
public TableMeasurer(Table table, RenderContext options)
: base(table, options)
{
_explicitWidth = table.Width;
_border = table.Border;
_padRightCell = table.PadRightCell;
}
public int CalculateTotalCellWidth(int maxWidth)
{
var totalCellWidth = maxWidth;
if (_explicitWidth != null)
{
totalCellWidth = Math.Min(_explicitWidth.Value, maxWidth);
}
return totalCellWidth - GetNonColumnWidth();
}
/// <summary>
/// Gets the width of everything that's not a cell.
/// That means separators, edges and padding.
/// </summary>
/// <returns>The width of everything that's not a cell.</returns>
public int GetNonColumnWidth()
{
var hideBorder = !_border.Visible;
var separators = hideBorder ? 0 : Columns.Count - 1;
var edges = hideBorder ? 0 : EdgeCount;
var padding = Columns.Select(x => x.Padding?.GetWidth() ?? 0).Sum();
if (!_padRightCell)
{
padding -= Columns.Last().Padding.GetRightSafe();
}
return separators + edges + padding;
}
/// <summary>
/// Calculates the width of all columns minus any padding.
/// </summary>
/// <param name="maxWidth">The maximum width that the columns may occupy.</param>
/// <returns>A list of column widths.</returns>
public List<int> CalculateColumnWidths(int maxWidth)
{
var width_ranges = Columns.Select(column => MeasureColumn(column, maxWidth)).ToArray();
var widths = width_ranges.Select(range => range.Max).ToList();
var tableWidth = widths.Sum();
if (tableWidth > maxWidth)
{
var wrappable = Columns.Select(c => !c.NoWrap).ToList();
widths = CollapseWidths(widths, wrappable, maxWidth);
tableWidth = widths.Sum();
// last resort, reduce columns evenly
if (tableWidth > maxWidth)
{
var excessWidth = tableWidth - maxWidth;
widths = Ratio.Reduce(excessWidth, widths.Select(_ => 1).ToList(), widths, widths);
tableWidth = widths.Sum();
}
}
if (tableWidth < maxWidth && Expand)
{
var padWidths = Ratio.Distribute(maxWidth - tableWidth, widths);
widths = widths.Zip(padWidths, (a, b) => (a, b)).Select(f => f.a + f.b).ToList();
}
return widths;
}
public Measurement MeasureColumn(TableColumn column, int maxWidth)
{
// Predetermined width?
if (column.Width != null)
{
return new Measurement(column.Width.Value, column.Width.Value);
}
var columnIndex = Columns.IndexOf(column);
var rows = Rows.Select(row => row[columnIndex]);
var minWidths = new List<int>();
var maxWidths = new List<int>();
// Include columns (both header and footer) in measurement
var headerMeasure = column.Header.Measure(Options, maxWidth);
var footerMeasure = column.Footer?.Measure(Options, maxWidth) ?? headerMeasure;
minWidths.Add(Math.Min(headerMeasure.Min, footerMeasure.Min));
maxWidths.Add(Math.Max(headerMeasure.Max, footerMeasure.Max));
foreach (var row in rows)
{
var rowMeasure = row.Measure(Options, maxWidth);
minWidths.Add(rowMeasure.Min);
maxWidths.Add(rowMeasure.Max);
}
var padding = column.Padding?.GetWidth() ?? 0;
return new Measurement(
minWidths.Count > 0 ? minWidths.Max() : padding,
maxWidths.Count > 0 ? maxWidths.Max() : maxWidth);
}
// Reduce widths so that the total is less or equal to the max width.
// Ported from Rich by Will McGugan, licensed under MIT.
// https://github.com/willmcgugan/rich/blob/527475837ebbfc427530b3ee0d4d0741d2d0fc6d/rich/table.py#L442
private static List<int> CollapseWidths(List<int> widths, List<bool> wrappable, int maxWidth)
{
var totalWidth = widths.Sum();
var excessWidth = totalWidth - maxWidth;
if (wrappable.AnyTrue())
{
while (totalWidth != 0 && excessWidth > 0)
{
var maxColumn = widths.Zip(wrappable, (first, second) => (width: first, allowWrap: second))
.Where(x => x.allowWrap)
.Max(x => x.width);
var secondMaxColumn = widths.Zip(wrappable, (width, allowWrap) => allowWrap && width != maxColumn ? width : 1).Max();
var columnDifference = maxColumn - secondMaxColumn;
var ratios = widths.Zip(wrappable, (width, allowWrap) => width == maxColumn && allowWrap ? 1 : 0).ToList();
if (!ratios.Any(x => x != 0) || columnDifference == 0)
{
break;
}
var maxReduce = widths.Select(_ => Math.Min(excessWidth, columnDifference)).ToList();
widths = Ratio.Reduce(excessWidth, ratios, maxReduce, widths);
totalWidth = widths.Sum();
excessWidth = totalWidth - maxWidth;
}
}
return widths;
}
}
}

View File

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal static class TableRenderer
{
private static readonly Style _defaultHeadingStyle = new Style(Color.Silver);
private static readonly Style _defaultCaptionStyle = new Style(Color.Grey);
public static List<Segment> Render(TableRendererContext context, List<int> columnWidths)
{
// Can't render the table?
if (context.TableWidth <= 0 || context.TableWidth > context.MaxWidth || columnWidths.Any(c => c <= 0))
{
return new List<Segment>(new[] { new Segment("…", context.BorderStyle ?? Style.Plain) });
}
var result = new List<Segment>();
result.AddRange(RenderAnnotation(context, context.Title, _defaultHeadingStyle));
// Iterate all rows
foreach (var (index, isFirstRow, isLastRow, row) in context.Rows.Enumerate())
{
var cellHeight = 1;
// Get the list of cells for the row and calculate the cell height
var cells = new List<List<SegmentLine>>();
foreach (var (columnIndex, _, _, (rowWidth, cell)) in columnWidths.Zip(row).Enumerate())
{
var justification = context.Columns[columnIndex].Alignment;
var childContext = context.Options.WithJustification(justification);
var lines = Segment.SplitLines(context.Options, cell.Render(childContext, rowWidth));
cellHeight = Math.Max(cellHeight, lines.Count);
cells.Add(lines);
}
// Show top of header?
if (isFirstRow && context.ShowBorder)
{
var separator = Aligner.Align(context.Options, context.Border.GetColumnRow(TablePart.Top, columnWidths, context.Columns), context.Alignment, context.MaxWidth);
result.Add(new Segment(separator, context.BorderStyle));
result.Add(Segment.LineBreak);
}
// Show footer separator?
if (context.ShowFooters && isLastRow && context.ShowBorder && context.HasFooters)
{
var textBorder = context.Border.GetColumnRow(TablePart.FooterSeparator, columnWidths, context.Columns);
if (!string.IsNullOrEmpty(textBorder))
{
var separator = Aligner.Align(context.Options, textBorder, context.Alignment, context.MaxWidth);
result.Add(new Segment(separator, context.BorderStyle));
result.Add(Segment.LineBreak);
}
}
// Make cells the same shape
cells = Segment.MakeSameHeight(cellHeight, cells);
// Iterate through each cell row
foreach (var cellRowIndex in Enumerable.Range(0, cellHeight))
{
var rowResult = new List<Segment>();
foreach (var (cellIndex, isFirstCell, isLastCell, cell) in cells.Enumerate())
{
if (isFirstCell && context.ShowBorder)
{
// Show left column edge
var part = isFirstRow && context.ShowHeaders ? TableBorderPart.HeaderLeft : TableBorderPart.CellLeft;
rowResult.Add(new Segment(context.Border.GetPart(part), context.BorderStyle));
}
// Pad column on left side.
if (context.ShowBorder || context.IsGrid)
{
var leftPadding = context.Columns[cellIndex].Padding.GetLeftSafe();
if (leftPadding > 0)
{
rowResult.Add(new Segment(new string(' ', leftPadding)));
}
}
// Add content
rowResult.AddRange(cell[cellRowIndex]);
// Pad cell content right
var length = cell[cellRowIndex].Sum(segment => segment.CellCount(context.Options));
if (length < columnWidths[cellIndex])
{
rowResult.Add(new Segment(new string(' ', columnWidths[cellIndex] - length)));
}
// Pad column on the right side
if (context.ShowBorder || (context.HideBorder && !isLastCell) || (context.HideBorder && isLastCell && context.IsGrid && context.PadRightCell))
{
var rightPadding = context.Columns[cellIndex].Padding.GetRightSafe();
if (rightPadding > 0)
{
rowResult.Add(new Segment(new string(' ', rightPadding)));
}
}
if (isLastCell && context.ShowBorder)
{
// Add right column edge
var part = isFirstRow && context.ShowHeaders ? TableBorderPart.HeaderRight : TableBorderPart.CellRight;
rowResult.Add(new Segment(context.Border.GetPart(part), context.BorderStyle));
}
else if (context.ShowBorder)
{
// Add column separator
var part = isFirstRow && context.ShowHeaders ? TableBorderPart.HeaderSeparator : TableBorderPart.CellSeparator;
rowResult.Add(new Segment(context.Border.GetPart(part), context.BorderStyle));
}
}
// Align the row result.
Aligner.Align(context.Options, rowResult, context.Alignment, context.MaxWidth);
// Is the row larger than the allowed max width?
if (Segment.CellCount(context.Options, rowResult) > context.MaxWidth)
{
result.AddRange(Segment.Truncate(context.Options, rowResult, context.MaxWidth));
}
else
{
result.AddRange(rowResult);
}
result.Add(Segment.LineBreak);
}
// Show header separator?
if (isFirstRow && context.ShowBorder && context.ShowHeaders && context.HasRows)
{
var separator = Aligner.Align(context.Options, context.Border.GetColumnRow(TablePart.HeaderSeparator, columnWidths, context.Columns), context.Alignment, context.MaxWidth);
result.Add(new Segment(separator, context.BorderStyle));
result.Add(Segment.LineBreak);
}
// Show bottom of footer?
if (isLastRow && context.ShowBorder)
{
var separator = Aligner.Align(context.Options, context.Border.GetColumnRow(TablePart.Bottom, columnWidths, context.Columns), context.Alignment, context.MaxWidth);
result.Add(new Segment(separator, context.BorderStyle));
result.Add(Segment.LineBreak);
}
}
result.AddRange(RenderAnnotation(context, context.Caption, _defaultCaptionStyle));
return result;
}
private static IEnumerable<Segment> RenderAnnotation(TableRendererContext context, TableTitle? header, Style defaultStyle)
{
if (header == null)
{
return Array.Empty<Segment>();
}
var paragraph = new Markup(header.Text.Capitalize(), header.Style ?? defaultStyle)
.Alignment(Justify.Center)
.Overflow(Overflow.Ellipsis);
// Render the paragraphs
var segments = new List<Segment>();
segments.AddRange(((IRenderable)paragraph).Render(context.Options, context.TableWidth));
// Align over the whole buffer area
Aligner.Align(context.Options, segments, context.Alignment, context.MaxWidth);
segments.Add(Segment.LineBreak);
return segments;
}
}
}

View File

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class TableRendererContext : TableAccessor
{
private readonly Table _table;
private readonly List<TableRow> _rows;
public override IReadOnlyList<TableRow> Rows => _rows;
public TableBorder Border { get; }
public Style BorderStyle { get; }
public bool ShowBorder { get; }
public bool HasRows { get; }
public bool HasFooters { get; }
/// <summary>
/// Gets the max width of the destination area.
/// The table might take up less than this.
/// </summary>
public int MaxWidth { get; }
/// <summary>
/// Gets the width of the table.
/// </summary>
public int TableWidth { get; }
public bool HideBorder => !ShowBorder;
public bool ShowHeaders => _table.ShowHeaders;
public bool ShowFooters => _table.ShowFooters;
public bool IsGrid => _table.IsGrid;
public bool PadRightCell => _table.PadRightCell;
public TableTitle? Title => _table.Title;
public TableTitle? Caption => _table.Caption;
public Justify? Alignment => _table.Alignment;
public TableRendererContext(Table table, RenderContext options, IEnumerable<TableRow> rows, int tableWidth, int maxWidth)
: base(table, options)
{
_table = table ?? throw new ArgumentNullException(nameof(table));
_rows = new List<TableRow>(rows ?? Enumerable.Empty<TableRow>());
ShowBorder = _table.Border.Visible;
HasRows = Rows.Any(row => !row.IsHeader && !row.IsFooter);
HasFooters = Rows.Any(column => column.IsFooter);
Border = table.Border.GetSafeBorder((options.LegacyConsole || !options.Unicode) && table.UseSafeBorder);
BorderStyle = table.BorderStyle ?? Style.Plain;
TableWidth = tableWidth;
MaxWidth = maxWidth;
}
}
}

View File

@ -12,6 +12,9 @@ namespace Spectre.Console
{
private readonly List<IRenderable> _items;
internal bool IsHeader { get; }
internal bool IsFooter { get; }
/// <summary>
/// Gets a row item at the specified table column index.
/// </summary>
@ -27,8 +30,26 @@ namespace Spectre.Console
/// </summary>
/// <param name="items">The row items.</param>
public TableRow(IEnumerable<IRenderable> items)
: this(items, false, false)
{
}
private TableRow(IEnumerable<IRenderable> items, bool isHeader, bool isFooter)
{
_items = new List<IRenderable>(items ?? Array.Empty<IRenderable>());
IsHeader = isHeader;
IsFooter = isFooter;
}
internal static TableRow Header(IEnumerable<IRenderable> items)
{
return new TableRow(items, true, false);
}
internal static TableRow Footer(IEnumerable<IRenderable> items)
{
return new TableRow(items, false, true);
}
internal void Add(IRenderable item)