diff --git a/src/Spectre.Console.Tests/Spectre.Console.Tests.v3.ncrunchproject b/src/Spectre.Console.Tests/Spectre.Console.Tests.v3.ncrunchproject new file mode 100644 index 0000000..2bc3e06 --- /dev/null +++ b/src/Spectre.Console.Tests/Spectre.Console.Tests.v3.ncrunchproject @@ -0,0 +1,18 @@ + + + + + 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 + + + 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 + + + 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 + + + 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 + + + + \ No newline at end of file diff --git a/src/Spectre.Console/Extensions/EnumerableExtensions.cs b/src/Spectre.Console/Extensions/EnumerableExtensions.cs index 3172af7..0ee405d 100644 --- a/src/Spectre.Console/Extensions/EnumerableExtensions.cs +++ b/src/Spectre.Console/Extensions/EnumerableExtensions.cs @@ -6,6 +6,23 @@ namespace Spectre.Console.Internal { internal static class EnumerableExtensions { + public static int IndexOf(this IEnumerable 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(this IEnumerable source) { if (source is IList list) diff --git a/src/Spectre.Console/Widgets/Table.cs b/src/Spectre.Console/Widgets/Table.cs deleted file mode 100644 index e98f61e..0000000 --- a/src/Spectre.Console/Widgets/Table.cs +++ /dev/null @@ -1,501 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Spectre.Console.Internal; -using Spectre.Console.Rendering; - -namespace Spectre.Console -{ - /// - /// A renderable table. - /// - public sealed class Table : Renderable, IHasTableBorder, IExpandable, IAlignable - { - private const int EdgeCount = 2; - - private readonly List _columns; - private readonly List _rows; - - private static readonly Style _defaultHeadingStyle = new Style(Color.Silver); - private static readonly Style _defaultCaptionStyle = new Style(Color.Grey); - - /// - /// Gets the table columns. - /// - public IReadOnlyList Columns => _columns; - - /// - /// Gets the table rows. - /// - public IReadOnlyList Rows => _rows; - - /// - public TableBorder Border { get; set; } = TableBorder.Square; - - /// - public Style? BorderStyle { get; set; } - - /// - public bool UseSafeBorder { get; set; } = true; - - /// - /// Gets or sets a value indicating whether or not table headers should be shown. - /// - public bool ShowHeaders { get; set; } = true; - - /// - /// Gets or sets a value indicating whether or not table footers should be shown. - /// - public bool ShowFooters { get; set; } = true; - - /// - /// Gets or sets a value indicating whether or not the table should - /// fit the available space. If false, the table width will be - /// auto calculated. Defaults to false. - /// - public bool Expand { get; set; } - - /// - /// Gets or sets the width of the table. - /// - public int? Width { get; set; } - - /// - /// Gets or sets the table title. - /// - public TableTitle? Title { get; set; } - - /// - /// Gets or sets the table footnote. - /// - public TableTitle? Caption { get; set; } - - /// - 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; - - /// - /// Initializes a new instance of the class. - /// - public Table() - { - _columns = new List(); - _rows = new List(); - } - - /// - /// Adds a column to the table. - /// - /// The column to add. - /// The same instance so that multiple calls can be chained. - 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; - } - - /// - /// Adds a row to the table. - /// - /// The row columns to add. - /// The same instance so that multiple calls can be chained. - public Table AddRow(IEnumerable 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; - } - - /// - 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); - } - - /// - protected override IEnumerable 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(new[] { new Segment("…", BorderStyle ?? Style.Plain) }); - } - - var rows = new List(); - if (ShowHeaders) - { - // Add columns to top of rows - rows.Add(new TableRow(new List(_columns.Select(c => c.Header)))); - } - - // Add rows. - rows.AddRange(_rows); - - if (hasFooters) - { - rows.Add(new TableRow(new List(_columns.Select(c => c.Footer ?? Text.Empty)))); - } - - var result = new List(); - 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>(); - 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(); - - 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 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 CollapseWidths(List widths, List 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 RenderAnnotation( - RenderContext context, TableTitle? header, - int maxWidth, int tableWidth, Style defaultStyle) - { - if (header == null) - { - return Array.Empty(); - } - - var paragraph = new Markup(header.Text.Capitalize(), header.Style ?? defaultStyle) - .Alignment(Justify.Center) - .Overflow(Overflow.Ellipsis); - - var items = new List(); - 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(); - var maxWidths = new List(); - - // 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; - } - } -} diff --git a/src/Spectre.Console/Widgets/Table/Table.cs b/src/Spectre.Console/Widgets/Table/Table.cs new file mode 100644 index 0000000..f0ff0da --- /dev/null +++ b/src/Spectre.Console/Widgets/Table/Table.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A renderable table. + /// + public sealed class Table : Renderable, IHasTableBorder, IExpandable, IAlignable + { + private readonly List _columns; + private readonly List _rows; + + /// + /// Gets the table columns. + /// + public IReadOnlyList Columns => _columns; + + /// + /// Gets the table rows. + /// + public IReadOnlyList Rows => _rows; + + /// + public TableBorder Border { get; set; } = TableBorder.Square; + + /// + public Style? BorderStyle { get; set; } + + /// + public bool UseSafeBorder { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not table headers should be shown. + /// + public bool ShowHeaders { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not table footers should be shown. + /// + public bool ShowFooters { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not the table should + /// fit the available space. If false, the table width will be + /// auto calculated. Defaults to false. + /// + public bool Expand { get; set; } + + /// + /// Gets or sets the width of the table. + /// + public int? Width { get; set; } + + /// + /// Gets or sets the table title. + /// + public TableTitle? Title { get; set; } + + /// + /// Gets or sets the table footnote. + /// + public TableTitle? Caption { get; set; } + + /// + 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; + + /// + /// Initializes a new instance of the class. + /// + public Table() + { + _columns = new List(); + _rows = new List(); + } + + /// + /// Adds a column to the table. + /// + /// The column to add. + /// The same instance so that multiple calls can be chained. + 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; + } + + /// + /// Adds a row to the table. + /// + /// The row columns to add. + /// The same instance so that multiple calls can be chained. + public Table AddRow(IEnumerable 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; + } + + /// + 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); + } + + /// + protected override IEnumerable 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 GetRenderableRows() + { + var rows = new List(); + + // 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; + } + } +} diff --git a/src/Spectre.Console/Widgets/Table/TableAccessor.cs b/src/Spectre.Console/Widgets/Table/TableAccessor.cs new file mode 100644 index 0000000..ded3dd7 --- /dev/null +++ b/src/Spectre.Console/Widgets/Table/TableAccessor.cs @@ -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 Columns => _table.Columns; + public virtual IReadOnlyList 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)); + } + } +} diff --git a/src/Spectre.Console/Widgets/TableColumn.cs b/src/Spectre.Console/Widgets/Table/TableColumn.cs similarity index 100% rename from src/Spectre.Console/Widgets/TableColumn.cs rename to src/Spectre.Console/Widgets/Table/TableColumn.cs diff --git a/src/Spectre.Console/Widgets/Table/TableMeasurer.cs b/src/Spectre.Console/Widgets/Table/TableMeasurer.cs new file mode 100644 index 0000000..fa474aa --- /dev/null +++ b/src/Spectre.Console/Widgets/Table/TableMeasurer.cs @@ -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(); + } + + /// + /// Gets the width of everything that's not a cell. + /// That means separators, edges and padding. + /// + /// The width of everything that's not a cell. + 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; + } + + /// + /// Calculates the width of all columns minus any padding. + /// + /// The maximum width that the columns may occupy. + /// A list of column widths. + public List 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(); + var maxWidths = new List(); + + // 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 CollapseWidths(List widths, List 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; + } + } +} diff --git a/src/Spectre.Console/Widgets/Table/TableRenderer.cs b/src/Spectre.Console/Widgets/Table/TableRenderer.cs new file mode 100644 index 0000000..e82b571 --- /dev/null +++ b/src/Spectre.Console/Widgets/Table/TableRenderer.cs @@ -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 Render(TableRendererContext context, List columnWidths) + { + // Can't render the table? + if (context.TableWidth <= 0 || context.TableWidth > context.MaxWidth || columnWidths.Any(c => c <= 0)) + { + return new List(new[] { new Segment("…", context.BorderStyle ?? Style.Plain) }); + } + + var result = new List(); + 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>(); + 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(); + + 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 RenderAnnotation(TableRendererContext context, TableTitle? header, Style defaultStyle) + { + if (header == null) + { + return Array.Empty(); + } + + var paragraph = new Markup(header.Text.Capitalize(), header.Style ?? defaultStyle) + .Alignment(Justify.Center) + .Overflow(Overflow.Ellipsis); + + // Render the paragraphs + var segments = new List(); + 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; + } + } +} diff --git a/src/Spectre.Console/Widgets/Table/TableRendererContext.cs b/src/Spectre.Console/Widgets/Table/TableRendererContext.cs new file mode 100644 index 0000000..0ba486d --- /dev/null +++ b/src/Spectre.Console/Widgets/Table/TableRendererContext.cs @@ -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 _rows; + + public override IReadOnlyList Rows => _rows; + + public TableBorder Border { get; } + public Style BorderStyle { get; } + public bool ShowBorder { get; } + public bool HasRows { get; } + public bool HasFooters { get; } + + /// + /// Gets the max width of the destination area. + /// The table might take up less than this. + /// + public int MaxWidth { get; } + + /// + /// Gets the width of the table. + /// + 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 rows, int tableWidth, int maxWidth) + : base(table, options) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _rows = new List(rows ?? Enumerable.Empty()); + + 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; + } + } +} diff --git a/src/Spectre.Console/Widgets/TableRow.cs b/src/Spectre.Console/Widgets/Table/TableRow.cs similarity index 71% rename from src/Spectre.Console/Widgets/TableRow.cs rename to src/Spectre.Console/Widgets/Table/TableRow.cs index 2ad6466..7eeb356 100644 --- a/src/Spectre.Console/Widgets/TableRow.cs +++ b/src/Spectre.Console/Widgets/Table/TableRow.cs @@ -12,6 +12,9 @@ namespace Spectre.Console { private readonly List _items; + internal bool IsHeader { get; } + internal bool IsFooter { get; } + /// /// Gets a row item at the specified table column index. /// @@ -27,8 +30,26 @@ namespace Spectre.Console /// /// The row items. public TableRow(IEnumerable items) + : this(items, false, false) + { + } + + private TableRow(IEnumerable items, bool isHeader, bool isFooter) { _items = new List(items ?? Array.Empty()); + + IsHeader = isHeader; + IsFooter = isFooter; + } + + internal static TableRow Header(IEnumerable items) + { + return new TableRow(items, true, false); + } + + internal static TableRow Footer(IEnumerable items) + { + return new TableRow(items, false, true); } internal void Add(IRenderable item) diff --git a/src/Spectre.Console/Widgets/TableTitle.cs b/src/Spectre.Console/Widgets/Table/TableTitle.cs similarity index 100% rename from src/Spectre.Console/Widgets/TableTitle.cs rename to src/Spectre.Console/Widgets/Table/TableTitle.cs