Add better algorithm for calculating column widths

Closes #14
This commit is contained in:
Patrik Svensson 2020-08-05 16:25:09 +02:00 committed by Patrik Svensson
parent 0b4321115a
commit 9637066927
18 changed files with 751 additions and 150 deletions

View File

@ -17,23 +17,18 @@ namespace Sample
AnsiConsole.MarkupLine("[white on red]Good[/] [red]bye[/]!");
AnsiConsole.WriteLine();
// We can also use System.ConsoleColor with AnsiConsole.
// To set the Foreground color
// We can also use System.ConsoleColor with AnsiConsole
// to set the foreground and background color.
foreach (ConsoleColor value in Enum.GetValues(typeof(ConsoleColor)))
{
AnsiConsole.Foreground = value;
AnsiConsole.WriteLine("Foreground: ConsoleColor.{0}", value);
}
var foreground = value;
var background = (ConsoleColor)(15 - (int)value);
AnsiConsole.WriteLine();
AnsiConsole.Foreground = Color.Chartreuse2;
// As well as the background color
foreach (ConsoleColor value in Enum.GetValues(typeof(ConsoleColor)))
{
AnsiConsole.Background = value;
AnsiConsole.WriteLine("Background: ConsoleColor.{0}", value);
AnsiConsole.Foreground = foreground;
AnsiConsole.Background = background;
AnsiConsole.WriteLine("{0} on {1}", foreground, background);
AnsiConsole.ResetColors();
}
AnsiConsole.Reset();
// We can get the default console via the static API.
var console = AnsiConsole.Console;
@ -96,6 +91,7 @@ namespace Sample
foreground: Color.White),
fit: true, content: Justify.Right));
// A normal, square table
var table = new Table();
table.AddColumns("[red underline]Foo[/]", "Bar");
table.AddRow("[blue][underline]Hell[/]o[/]", "World 🌍");
@ -104,7 +100,8 @@ namespace Sample
table.AddRow("Hej 👋", "[green]Världen[/]");
AnsiConsole.Render(table);
table = new Table(BorderKind.Rounded);
// A rounded table
table = new Table { Border = BorderKind.Rounded };
table.AddColumns("[red underline]Foo[/]", "Bar");
table.AddRow("[blue][underline]Hell[/]o[/]", "World 🌍");
table.AddRow("[yellow]Patrik [green]\"Lol[/]\" Svensson[/]", "Was [underline]here[/]!");
@ -112,16 +109,52 @@ namespace Sample
table.AddRow("Hej 👋", "[green]Världen[/]");
AnsiConsole.Render(table);
// A rounded table without headers
table = new Table { Border = BorderKind.Rounded, ShowHeaders = false };
table.AddColumns("[red underline]Foo[/]", "Bar");
table.AddRow("[blue][underline]Hell[/]o[/]", "World 🌍");
table.AddRow("[yellow]Patrik [green]\"Lol[/]\" Svensson[/]", "Was [underline]here[/]!");
table.AddRow("Lorem ipsum dolor sit amet, consectetur [blue]adipiscing[/] elit,sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", "◀ Strange language");
table.AddRow("Hej 👋", "[green]Världen[/]");
AnsiConsole.Render(table);
// Emulate the usage information for "dotnet run"
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(" Usage: [grey]dotnet [blue]run[/] [[options] [[[[--] <additional arguments>...]][/]");
AnsiConsole.MarkupLine("Usage: [grey]dotnet [blue]run[/] [[options] [[[[--] <additional arguments>...]][/]");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumns(3);
grid.AddRow(" Options", "", "");
grid.AddRow(" [blue]-h[/], [blue]--help[/]", " ", "Show command line help.");
grid.AddRow(" [blue]-c[/], [blue]--configuration[/] <CONFIGURATION>", " ", "The configuration to run for.\nThe default for most projects is [green]Debug[/].");
grid.AddRow(" [blue]-v[/], [blue]--verbosity[/] <LEVEL>", " ", "Set the MSBuild verbosity level.\nAllowed values are q[grey][[uiet][/], m[grey][[inimal][/], n[grey][[ormal][/], d[grey][[etailed][/], and diag[grey][[nostic][/].");
grid.AddColumn(new GridColumn { NoWrap = true });
grid.AddColumn(new GridColumn { NoWrap = true, Width = 2 });
grid.AddColumn();
grid.AddRow("Options:", "", "");
grid.AddRow(" [blue]-h[/], [blue]--help[/]", "", "Show command line help.");
grid.AddRow(" [blue]-c[/], [blue]--configuration[/] <CONFIGURATION>", "", "The configuration to run for.\nThe default for most projects is [green]Debug[/].");
grid.AddRow(" [blue]-v[/], [blue]--verbosity[/] <LEVEL>", "", "Set the MSBuild verbosity level. Allowed values are \nq[grey][[uiet][/], m[grey][[inimal][/], n[grey][[ormal][/], d[grey][[etailed][/], and diag[grey][[nostic][/].");
AnsiConsole.Render(grid);
// A simple table
AnsiConsole.WriteLine();
table = new Table { Border = BorderKind.Rounded };
table.AddColumn("Foo");
table.AddColumn("Bar");
table.AddColumn("Baz");
table.AddRow("Qux\nQuuuuuux", "[blue]Corgi[/]", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
AnsiConsole.Render(table);
// Render a table in some panels.
AnsiConsole.Render(new Panel(new Panel(table, border: BorderKind.Ascii)));
// Draw another table
table = new Table { Expand = false };
table.AddColumn(new TableColumn("Date"));
table.AddColumn(new TableColumn("Title"));
table.AddColumn(new TableColumn("Production\nBudget"));
table.AddColumn(new TableColumn("Box Office"));
table.AddRow("Dec 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "[red]$375,126,118[/]");
table.AddRow("May 25, 2018", "[yellow]Solo[/]: A Star Wars Story", "$275,000,000", "$393,151,347");
table.AddRow("Dec 15, 2017", "Star Wars Ep. VIII: The Last Jedi", "$262,000,000", "[bold green]$1,332,539,889[/]");
AnsiConsole.Render(table);
}
}
}

View File

@ -246,6 +246,23 @@ namespace Spectre.Console.Tests.Unit
public sealed class WriteLine
{
[Fact]
public void Should_Reset_Colors_Correctly()
{
// Given
var fixture = new AnsiConsoleFixture(ColorSystem.Standard, AnsiSupport.Yes);
// When
fixture.Console.Background = ConsoleColor.Red;
fixture.Console.WriteLine("Hello");
fixture.Console.Background = ConsoleColor.Green;
fixture.Console.WriteLine("World");
// Then
fixture.Output.NormalizeLineEndings()
.ShouldBe("Hello\nWorld\n");
}
[Theory]
[InlineData(AnsiSupport.Yes)]
[InlineData(AnsiSupport.No)]

View File

@ -27,7 +27,8 @@ namespace Spectre.Console.Tests.Unit.Composition
{
// Given
var grid = new Grid();
grid.AddColumns(2);
grid.AddColumn();
grid.AddColumn();
// When
var result = Record.Exception(() => grid.AddRow("Foo"));
@ -59,7 +60,9 @@ namespace Spectre.Console.Tests.Unit.Composition
// Given
var console = new PlainConsole(width: 80);
var grid = new Grid();
grid.AddColumns(3);
grid.AddColumn();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("Qux", "Corgi", "Waldo");
grid.AddRow("Grault", "Garply", "Fred");
@ -75,22 +78,23 @@ namespace Spectre.Console.Tests.Unit.Composition
[Fact]
public void Should_Render_Grid()
{
var console = new PlainConsole(width: 120);
var console = new PlainConsole(width: 80);
var grid = new Grid();
grid.AddColumns(3);
grid.AddRow("[bold]Options[/]", string.Empty, string.Empty);
grid.AddRow(" [blue]-h[/], [blue]--help[/]", " ", "Show command line help.");
grid.AddRow(" [blue]-c[/], [blue]--configuration[/]", " ", "The configuration to run for.\nThe default for most projects is [green]Debug[/].");
grid.AddColumn(new GridColumn { NoWrap = true });
grid.AddColumn();
grid.AddRow("[bold]Options[/]", string.Empty);
grid.AddRow(" [blue]-h[/], [blue]--help[/]", "Show command line help.");
grid.AddRow(" [blue]-c[/], [blue]--configuration[/]", "The configuration to run for.\nThe default for most projects is [green]Debug[/].");
// When
console.Render(grid);
// Then
console.Lines.Count.ShouldBe(4);
console.Lines[0].ShouldBe("Options ");
console.Lines[1].ShouldBe(" -h, --help Show command line help. ");
console.Lines[2].ShouldBe(" -c, --configuration The configuration to run for. ");
console.Lines[3].ShouldBe(" The default for most projects is Debug.");
console.Lines[0].ShouldBe("Options ");
console.Lines[1].ShouldBe(" -h, --help Show command line help. ");
console.Lines[2].ShouldBe(" -c, --configuration The configuration to run for. ");
console.Lines[3].ShouldBe(" The default for most projects is Debug. ");
}
}
}

View File

@ -15,7 +15,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var table = new Table();
// When
var result = Record.Exception(() => table.AddColumn(null));
var result = Record.Exception(() => table.AddColumn((string)null));
// Then
result.ShouldBeOfType<ArgumentNullException>()
@ -88,6 +88,31 @@ namespace Spectre.Console.Tests.Unit.Composition
}
}
[Fact]
public void Should_Measure_Table_Correctly()
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table();
table.AddColumns("Foo", "Bar", "Baz");
table.AddRow("Qux", "Corgi", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
// When
console.Render(new Panel(table));
// Then
console.Lines.Count.ShouldBe(8);
console.Lines[0].ShouldBe("┌─────────────────────────────┐");
console.Lines[1].ShouldBe("│ ┌────────┬────────┬───────┐ │");
console.Lines[2].ShouldBe("│ │ Foo │ Bar │ Baz │ │");
console.Lines[3].ShouldBe("│ ├────────┼────────┼───────┤ │");
console.Lines[4].ShouldBe("│ │ Qux │ Corgi │ Waldo │ │");
console.Lines[5].ShouldBe("│ │ Grault │ Garply │ Fred │ │");
console.Lines[6].ShouldBe("│ └────────┴────────┴───────┘ │");
console.Lines[7].ShouldBe("└─────────────────────────────┘");
}
[Fact]
public void Should_Render_Table_Correctly()
{
@ -102,6 +127,7 @@ namespace Spectre.Console.Tests.Unit.Composition
console.Render(table);
// Then
console.Lines.Count.ShouldBe(6);
console.Lines[0].ShouldBe("┌────────┬────────┬───────┐");
console.Lines[1].ShouldBe("│ Foo │ Bar │ Baz │");
console.Lines[2].ShouldBe("├────────┼────────┼───────┤");
@ -111,11 +137,11 @@ namespace Spectre.Console.Tests.Unit.Composition
}
[Fact]
public void Should_Render_Table_With_Ascii_Border_Correctly()
public void Should_Expand_Table_To_Available_Space_If_Specified()
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table(BorderKind.Ascii);
var table = new Table() { Expand = true };
table.AddColumns("Foo", "Bar", "Baz");
table.AddRow("Qux", "Corgi", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
@ -124,6 +150,31 @@ namespace Spectre.Console.Tests.Unit.Composition
console.Render(table);
// Then
console.Lines.Count.ShouldBe(6);
console.Lines[0].Length.ShouldBe(80);
console.Lines[0].ShouldBe("┌───────────────────────────┬───────────────────────────┬──────────────────────┐");
console.Lines[1].ShouldBe("│ Foo │ Bar │ Baz │");
console.Lines[2].ShouldBe("├───────────────────────────┼───────────────────────────┼──────────────────────┤");
console.Lines[3].ShouldBe("│ Qux │ Corgi │ Waldo │");
console.Lines[4].ShouldBe("│ Grault │ Garply │ Fred │");
console.Lines[5].ShouldBe("└───────────────────────────┴───────────────────────────┴──────────────────────┘");
}
[Fact]
public void Should_Render_Table_With_Ascii_Border_Correctly()
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table { Border = BorderKind.Ascii };
table.AddColumns("Foo", "Bar", "Baz");
table.AddRow("Qux", "Corgi", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
// When
console.Render(table);
// Then
console.Lines.Count.ShouldBe(6);
console.Lines[0].ShouldBe("+-------------------------+");
console.Lines[1].ShouldBe("| Foo | Bar | Baz |");
console.Lines[2].ShouldBe("|--------+--------+-------|");
@ -137,7 +188,7 @@ namespace Spectre.Console.Tests.Unit.Composition
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table(BorderKind.Rounded);
var table = new Table { Border = BorderKind.Rounded };
table.AddColumns("Foo", "Bar", "Baz");
table.AddRow("Qux", "Corgi", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
@ -146,6 +197,7 @@ namespace Spectre.Console.Tests.Unit.Composition
console.Render(table);
// Then
console.Lines.Count.ShouldBe(6);
console.Lines[0].ShouldBe("╭────────┬────────┬───────╮");
console.Lines[1].ShouldBe("│ Foo │ Bar │ Baz │");
console.Lines[2].ShouldBe("├────────┼────────┼───────┤");
@ -159,7 +211,7 @@ namespace Spectre.Console.Tests.Unit.Composition
{
// Given
var console = new PlainConsole(width: 80);
var table = new Table(BorderKind.None);
var table = new Table { Border = BorderKind.None };
table.AddColumns("Foo", "Bar", "Baz");
table.AddRow("Qux", "Corgi", "Waldo");
table.AddRow("Grault", "Garply", "Fred");
@ -188,6 +240,7 @@ namespace Spectre.Console.Tests.Unit.Composition
console.Render(table);
// Then
console.Lines.Count.ShouldBe(7);
console.Lines[0].ShouldBe("┌────────┬────────┬───────┐");
console.Lines[1].ShouldBe("│ Foo │ Bar │ Baz │");
console.Lines[2].ShouldBe("├────────┼────────┼───────┤");

View File

@ -17,23 +17,27 @@ namespace Spectre.Console
/// </summary>
public Grid()
{
_table = new Table(BorderKind.None, showHeaders: false);
_table = new Table
{
Border = BorderKind.None,
ShowHeaders = false,
};
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
public Measurement Measure(Encoding encoding, int maxWidth)
{
return _table.Measure(encoding, maxWidth);
return ((IRenderable)_table).Measure(encoding, maxWidth);
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
return _table.Render(encoding, width);
return ((IRenderable)_table).Render(encoding, width);
}
/// <summary>
/// Adds a single column to the grid.
/// Adds a column to the grid.
/// </summary>
public void AddColumn()
{
@ -41,15 +45,23 @@ namespace Spectre.Console
}
/// <summary>
/// Adds the specified number of columns to the grid.
/// Adds a column to the grid.
/// </summary>
/// <param name="count">The number of columns.</param>
public void AddColumns(int count)
/// <param name="column">The column to add.</param>
public void AddColumn(GridColumn column)
{
for (var i = 0; i < count; i++)
if (column is null)
{
_table.AddColumn(string.Empty);
throw new ArgumentNullException(nameof(column));
}
_table.AddColumn(new TableColumn(string.Empty)
{
Width = column.Width,
NoWrap = column.NoWrap,
LeftPadding = 0,
RightPadding = 1,
});
}
/// <summary>

View File

@ -0,0 +1,20 @@
namespace Spectre.Console
{
/// <summary>
/// Represents a grid column.
/// </summary>
public sealed class GridColumn
{
/// <summary>
/// Gets or sets the width of the column.
/// If <c>null</c>, the column will adapt to it's contents.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets a value indicating whether wrapping of
/// text within the column should be prevented.
/// </summary>
public bool NoWrap { get; set; }
}
}

View File

@ -13,8 +13,8 @@ namespace Spectre.Console.Composition
/// </summary>
/// <param name="encoding">The encoding to use.</param>
/// <param name="maxWidth">The maximum allowed width.</param>
/// <returns>The width of the object.</returns>
int Measure(Encoding encoding, int maxWidth);
/// <returns>The minimum and maximum width of the object.</returns>
Measurement Measure(Encoding encoding, int maxWidth);
/// <summary>
/// Renders the object.

View File

@ -0,0 +1,77 @@
using System;
namespace Spectre.Console.Composition
{
/// <summary>
/// Represents a measurement.
/// </summary>
public struct Measurement : IEquatable<Measurement>
{
/// <summary>
/// Gets the minimum width.
/// </summary>
public int Min { get; }
/// <summary>
/// Gets the maximum width.
/// </summary>
public int Max { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Measurement"/> struct.
/// </summary>
/// <param name="min">The minimum width.</param>
/// <param name="max">The maximum width.</param>
public Measurement(int min, int max)
{
Min = min;
Max = max;
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
return obj is Measurement measurement && Equals(measurement);
}
/// <inheritdoc/>
public override int GetHashCode()
{
unchecked
{
var hash = (int)2166136261;
hash = (hash * 16777619) ^ Min.GetHashCode();
hash = (hash * 16777619) ^ Max.GetHashCode();
return hash;
}
}
/// <inheritdoc/>
public bool Equals(Measurement other)
{
return Min == other.Min && Max == other.Max;
}
/// <summary>
/// Checks if two <see cref="Measurement"/> instances are equal.
/// </summary>
/// <param name="left">The first measurement instance to compare.</param>
/// <param name="right">The second measurement instance to compare.</param>
/// <returns><c>true</c> if the two measurements are equal, otherwise <c>false</c>.</returns>
public static bool operator ==(Measurement left, Measurement right)
{
return left.Equals(right);
}
/// <summary>
/// Checks if two <see cref="Measurement"/> instances are not equal.
/// </summary>
/// <param name="left">The first measurement instance to compare.</param>
/// <param name="right">The second measurement instance to compare.</param>
/// <returns><c>true</c> if the two measurements are not equal, otherwise <c>false</c>.</returns>
public static bool operator !=(Measurement left, Measurement right)
{
return !(left == right);
}
}
}

View File

@ -35,19 +35,20 @@ namespace Spectre.Console
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
Measurement IRenderable.Measure(Encoding encoding, int maxWidth)
{
var childWidth = _child.Measure(encoding, maxWidth);
return childWidth + 4;
return new Measurement(childWidth.Min + 4, childWidth.Max + 4);
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
IEnumerable<Segment> IRenderable.Render(Encoding encoding, int width)
{
var childWidth = width - 4;
if (!_fit)
{
childWidth = _child.Measure(encoding, width - 2);
var measurement = _child.Measure(encoding, width - 2);
childWidth = measurement.Max;
}
var result = new List<Segment>();

View File

@ -211,5 +211,21 @@ namespace Spectre.Console.Composition
return lines;
}
internal static List<List<SegmentLine>> MakeSameHeight(int cellHeight, List<List<SegmentLine>> cells)
{
foreach (var cell in cells)
{
if (cell.Count < cellHeight)
{
while (cell.Count != cellHeight)
{
cell.Add(new SegmentLine());
}
}
}
return cells;
}
}
}

View File

@ -0,0 +1,168 @@
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 partial class Table
{
// 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(Encoding encoding, int maxWidth)
{
var width_ranges = _columns.Select(column => MeasureColumn(column, encoding, maxWidth));
var widths = width_ranges.Select(range => range.Max).ToList();
var tableWidth = widths.Sum();
if (ShouldExpand())
{
var ratios = _columns.Select(c => c.Ratio ?? 0).ToList();
if (ratios.Any(r => r != 0))
{
var fixedWidths = new List<int>();
foreach (var (range, column) in width_ranges.Zip(_columns, (a, b) => (a, b)))
{
fixedWidths.Add(column.IsFlexible() ? 0 : range.Max);
}
var flexMinimum = new List<int>();
foreach (var column in _columns)
{
if (column.IsFlexible())
{
flexMinimum.Add(column.Width ?? 1 + column.GetPadding());
}
else
{
flexMinimum.Add(0);
}
}
var flexibleWidth = maxWidth - fixedWidths.Sum();
var flexWidths = Ratio.Distribute(flexibleWidth, ratios, flexMinimum);
var flexWidthsIterator = flexWidths.GetEnumerator();
foreach (var (index, _, _, column) in _columns.Enumerate())
{
if (column.IsFlexible())
{
flexWidthsIterator.MoveNext();
widths[index] = fixedWidths[index] + flexWidthsIterator.Current;
}
}
}
}
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(w => 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, isWrappable: second))
.Where(x => x.isWrappable)
.Max(x => x.width);
var secondMaxColumn = widths.Zip(wrappable, (width, isWrappable) => isWrappable && width != maxColumn ? maxColumn : 0).Max();
var columnDifference = maxColumn - secondMaxColumn;
var ratios = widths.Zip(wrappable, (width, allowWrap) =>
{
if (width == maxColumn && allowWrap)
{
return 1;
}
return 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 (int Min, int Max) MeasureColumn(TableColumn column, Encoding encoding, int maxWidth)
{
// Predetermined width?
if (column.Width != null)
{
var padding = column.GetPadding();
return (column.Width.Value + padding, column.Width.Value + padding);
}
var columnIndex = _columns.IndexOf(column);
var rows = _rows.Select(row => row[columnIndex]);
var minWidths = new List<int>();
var maxWidths = new List<int>();
foreach (var row in rows)
{
var measure = ((IRenderable)row).Measure(encoding, maxWidth);
minWidths.Add(measure.Min);
maxWidths.Add(measure.Max);
}
return (minWidths.Count > 0 ? minWidths.Max() : 1,
maxWidths.Count > 0 ? maxWidths.Max() : maxWidth);
}
private int GetExtraWidth(bool includePadding)
{
var edges = 2;
var separators = _columns.Count - 1;
var padding = includePadding ? _columns.Select(x => x.GetPadding()).Sum() : 0;
return separators + edges + padding;
}
}
}

View File

@ -10,31 +10,50 @@ namespace Spectre.Console
/// <summary>
/// Represents a table.
/// </summary>
public sealed class Table : IRenderable
public sealed partial class Table : IRenderable
{
private readonly List<Text> _columns;
private readonly List<TableColumn> _columns;
private readonly List<List<Text>> _rows;
private readonly Border _border;
private readonly BorderKind _borderKind;
private readonly bool _showHeaders;
/// <summary>
/// Gets the number of columns in the table.
/// </summary>
public int ColumnCount => _columns.Count;
/// <summary>
/// Gets the number of rows in the table.
/// </summary>
public int RowCount => _rows.Count;
/// <summary>
/// Gets or sets the kind of border to use.
/// </summary>
public BorderKind Border { get; set; } = BorderKind.Square;
/// <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 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; } = false;
/// <summary>
/// Gets or sets the width of the table.
/// </summary>
public int? Width { get; set; } = null;
/// <summary>
/// Initializes a new instance of the <see cref="Table"/> class.
/// </summary>
/// <param name="border">The border to use.</param>
/// <param name="showHeaders">Whether or not to show table headers.</param>
public Table(BorderKind border = BorderKind.Square, bool showHeaders = true)
public Table()
{
_columns = new List<Text>();
_columns = new List<TableColumn>();
_rows = new List<List<Text>>();
_border = Border.GetBorder(border);
_borderKind = border;
_showHeaders = showHeaders;
}
/// <summary>
@ -48,7 +67,21 @@ namespace Spectre.Console
throw new ArgumentNullException(nameof(column));
}
_columns.Add(Text.New(column));
_columns.Add(new TableColumn(column));
}
/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="column">The column to add.</param>
public void AddColumn(TableColumn column)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
_columns.Add(column);
}
/// <summary>
@ -62,7 +95,7 @@ namespace Spectre.Console
throw new ArgumentNullException(nameof(columns));
}
_columns.AddRange(columns.Select(column => Text.New(column)));
_columns.AddRange(columns.Select(column => new TableColumn(column)));
}
/// <summary>
@ -90,66 +123,55 @@ namespace Spectre.Console
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
Measurement IRenderable.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++)
if (Width != null)
{
for (var columnIndex = 0; columnIndex < _rows[rowIndex].Count; columnIndex++)
{
var columnWidth = _rows[rowIndex][columnIndex].Measure(encoding, maxColumnWidth);
if (columnWidth > columnWidths[columnIndex])
{
columnWidths[columnIndex] = columnWidth;
}
}
maxWidth = Math.Min(Width.Value, maxWidth);
}
// 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);
maxWidth -= GetExtraWidth(includePadding: true);
var measurements = _columns.Select(column => MeasureColumn(column, encoding, 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/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
IEnumerable<Segment> IRenderable.Render(Encoding encoding, int width)
{
var showBorder = _borderKind != BorderKind.None;
var hideBorder = _borderKind == BorderKind.None;
var border = Composition.Border.GetBorder(Border);
var leftRightBorderWidth = _borderKind == BorderKind.None ? 0 : 2;
var columnPadding = _borderKind == BorderKind.None ? _columns.Count : _columns.Count * 2;
var separatorCount = _borderKind == BorderKind.None ? 0 : _columns.Count - 1;
var showBorder = Border != BorderKind.None;
var hideBorder = Border == BorderKind.None;
// Calculate the max width for each column.
var maxColumnWidth = (width - (leftRightBorderWidth + columnPadding + separatorCount)) / _columns.Count;
var columnWidths = _columns.Select(c => c.Measure(encoding, maxColumnWidth)).ToArray();
for (var rowIndex = 0; rowIndex < _rows.Count; rowIndex++)
var maxWidth = width;
if (Width != null)
{
for (var columnIndex = 0; columnIndex < _rows[rowIndex].Count; columnIndex++)
{
var columnWidth = _rows[rowIndex][columnIndex].Measure(encoding, maxColumnWidth);
if (columnWidth > columnWidths[columnIndex])
{
columnWidths[columnIndex] = columnWidth;
}
}
maxWidth = Math.Min(Width.Value, maxWidth);
}
// We now know the max width of each column, so let's recalculate the width
width = columnWidths.Sum() + leftRightBorderWidth + columnPadding + separatorCount;
maxWidth -= GetExtraWidth(includePadding: true);
// Calculate the column and table widths
var columnWidths = CalculateColumnWidths(encoding, maxWidth);
// Update the table width.
width = columnWidths.Sum() + GetExtraWidth(includePadding: false);
var rows = new List<List<Text>>();
if (_showHeaders)
if (ShowHeaders)
{
// Add columns to top of rows
rows.Add(new List<Text>(_columns));
rows.Add(new List<Text>(_columns.Select(c => c.Text)));
}
// Add tows.
// Add rows.
rows.AddRange(_rows);
// Iterate all rows.
// Iterate all rows
var result = new List<Segment>();
foreach (var (index, firstRow, lastRow, row) in rows.Enumerate())
{
@ -159,7 +181,7 @@ namespace Spectre.Console
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));
var lines = Segment.SplitLines(((IRenderable)cell).Render(encoding, rowWidth));
cellHeight = Math.Max(cellHeight, lines.Count);
cells.Add(lines);
}
@ -167,20 +189,20 @@ namespace Spectre.Console
// Show top of header?
if (firstRow && showBorder)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTopLeft)));
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
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.HeaderTopSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.HeaderTopRight)));
result.Add(new Segment(border.GetPart(BorderPart.HeaderTopRight)));
result.Add(Segment.LineBreak());
}
@ -188,21 +210,24 @@ namespace Spectre.Console
foreach (var cellRowIndex in Enumerable.Range(0, cellHeight))
{
// Make cells the same shape
MakeSameHeight(cellHeight, cells);
cells = Segment.MakeSameHeight(cellHeight, cells);
var w00t = cells.Enumerate().ToArray();
foreach (var (cellIndex, firstCell, lastCell, cell) in w00t)
foreach (var (cellIndex, firstCell, lastCell, cell) in cells.Enumerate())
{
if (firstCell && showBorder)
{
// Show left column edge
result.Add(new Segment(_border.GetPart(BorderPart.CellLeft)));
result.Add(new Segment(border.GetPart(BorderPart.CellLeft)));
}
// Pad column on left side.
if (showBorder)
{
result.Add(new Segment(" "));
var leftPadding = _columns[cellIndex].LeftPadding;
if (leftPadding > 0)
{
result.Add(new Segment(new string(' ', leftPadding)));
}
}
// Add content
@ -218,18 +243,22 @@ namespace Spectre.Console
// Pad column on the right side
if (showBorder || (hideBorder && !lastCell))
{
result.Add(new Segment(" "));
var rightPadding = _columns[cellIndex].RightPadding;
if (rightPadding > 0)
{
result.Add(new Segment(new string(' ', rightPadding)));
}
}
if (lastCell && showBorder)
{
// Add right column edge
result.Add(new Segment(_border.GetPart(BorderPart.CellRight)));
result.Add(new Segment(border.GetPart(BorderPart.CellRight)));
}
else if (showBorder || (hideBorder && !lastCell))
{
// Add column separator
result.Add(new Segment(_border.GetPart(BorderPart.CellSeparator)));
result.Add(new Segment(border.GetPart(BorderPart.CellSeparator)));
}
}
@ -237,42 +266,42 @@ namespace Spectre.Console
}
// Show bottom of header?
if (firstRow && showBorder)
if (firstRow && showBorder && ShowHeaders)
{
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottomLeft)));
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
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.HeaderBottomSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.HeaderBottomRight)));
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottomRight)));
result.Add(Segment.LineBreak());
}
// Show bottom of footer?
if (lastRow && showBorder)
{
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottomLeft)));
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)));
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.FooterBottomSeparator)));
}
}
result.Add(new Segment(_border.GetPart(BorderPart.FooterBottomRight)));
result.Add(new Segment(border.GetPart(BorderPart.FooterBottomRight)));
result.Add(Segment.LineBreak());
}
}
@ -280,18 +309,9 @@ namespace Spectre.Console
return result;
}
private static void MakeSameHeight(int cellHeight, List<List<SegmentLine>> cells)
private bool ShouldExpand()
{
foreach (var cell in cells)
{
if (cell.Count < cellHeight)
{
while (cell.Count != cellHeight)
{
cell.Add(new SegmentLine());
}
}
}
return Expand || Width != null;
}
}
}

View File

@ -0,0 +1,67 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Represents a table column.
/// </summary>
public sealed class TableColumn
{
/// <summary>
/// Gets the text associated with the column.
/// </summary>
public Text Text { get; }
/// <summary>
/// Gets or sets the width of the column.
/// If <c>null</c>, the column will adapt to it's contents.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets the left padding.
/// </summary>
public int LeftPadding { get; set; }
/// <summary>
/// Gets or sets the right padding.
/// </summary>
public int RightPadding { get; set; }
/// <summary>
/// Gets or sets the ratio to use when calculating column width.
/// If <c>null</c>, the column will adapt to it's contents.
/// </summary>
public int? Ratio { get; set; }
/// <summary>
/// Gets or sets a value indicating whether wrapping of
/// text within the column should be prevented.
/// </summary>
public bool NoWrap { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="TableColumn"/> class.
/// </summary>
/// <param name="text">The table column text.</param>
public TableColumn(string text)
{
Text = Text.New(text ?? throw new ArgumentNullException(nameof(text)));
Width = null;
LeftPadding = 1;
RightPadding = 1;
Ratio = null;
NoWrap = false;
}
internal int GetPadding()
{
return LeftPadding + RightPadding;
}
internal bool IsFlexible()
{
return Width == null;
}
}
}

View File

@ -98,19 +98,22 @@ namespace Spectre.Console
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
Measurement IRenderable.Measure(Encoding encoding, int maxWidth)
{
var lines = Segment.SplitLines(Render(encoding, maxWidth));
var lines = Segment.SplitLines(((IRenderable)this).Render(encoding, maxWidth));
if (lines.Count == 0)
{
return 0;
return new Measurement(0, maxWidth);
}
return lines.Max(x => x.Length);
var max = lines.Max(line => line.Length);
var min = lines.SelectMany(line => line.Select(segment => segment.Text.Length)).Max();
return new Measurement(min, max);
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
IEnumerable<Segment> IRenderable.Render(Encoding encoding, int width)
{
if (string.IsNullOrWhiteSpace(_text))
{

View File

@ -1,5 +1,6 @@
using System;
using System.Globalization;
using Spectre.Console.Internal;
namespace Spectre.Console
{
@ -19,7 +20,12 @@ namespace Spectre.Console
throw new ArgumentNullException(nameof(console));
}
console.Write(Environment.NewLine);
using (console.PushColor(Color.Default, true))
using (console.PushColor(Color.Default, false))
using (console.PushDecoration(Decoration.None))
{
console.Write(Environment.NewLine);
}
}
/// <summary>

View File

@ -6,6 +6,19 @@ namespace Spectre.Console.Internal
{
internal static class EnumerableExtensions
{
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
{
foreach (var item in source)
{
action(item);
}
}
public static bool AnyTrue(this IEnumerable<bool> source)
{
return source.Any(b => b);
}
public static IEnumerable<(int Index, bool First, bool Last, T Item)> Enumerate<T>(this IEnumerable<T> source)
{
if (source is null)
@ -40,5 +53,18 @@ namespace Spectre.Console.Internal
{
return source.Select((value, index) => func(value, index));
}
public static IEnumerable<(TFirst First, TSecond Second)> Zip<TFirst, TSecond>(
this IEnumerable<TFirst> source, IEnumerable<TSecond> first)
{
return source.Zip(first, (first, second) => (first, second));
}
public static IEnumerable<(TFirst First, TSecond Second, TThird Third)> Zip<TFirst, TSecond, TThird>(
this IEnumerable<TFirst> first, IEnumerable<TSecond> second, IEnumerable<TThird> third)
{
return first.Zip(second, (a, b) => (a, b))
.Zip(third, (a, b) => (a.a, a.b, b));
}
}
}

View File

@ -0,0 +1,75 @@
// Ported from Rich by Will McGugan, licensed under MIT.
// https://github.com/willmcgugan/rich/blob/527475837ebbfc427530b3ee0d4d0741d2d0fc6d/rich/_ratio.py
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace Spectre.Console.Internal
{
internal static class Ratio
{
public static List<int> Reduce(int total, List<int> ratios, List<int> maximums, List<int> values)
{
ratios = ratios.Zip(maximums, (a, b) => (ratio: a, max: b)).Select(a => a.max > 0 ? a.ratio : 0).ToList();
var totalRatio = ratios.Sum();
if (totalRatio == 0)
{
return values;
}
var totalRemaining = total;
var result = new List<int>();
foreach (var (ratio, maximum, value) in ratios.Zip(maximums, values))
{
if (ratio > 0 && totalRatio > 0)
{
var distributed = (int)Math.Min(maximum, Math.Round(ratio * totalRemaining / (double)totalRatio));
result.Add(value - distributed);
totalRemaining -= distributed;
totalRatio -= ratio;
}
else
{
result.Add(value);
}
}
return result;
}
public static List<int> Distribute(int total, List<int> ratios, List<int> minimums = null)
{
if (minimums != null)
{
ratios = ratios.Zip(minimums, (a, b) => (ratio: a, min: b)).Select(a => a.min > 0 ? a.ratio : 0).ToList();
}
var totalRatio = ratios.Sum();
Debug.Assert(totalRatio > 0, "Sum or ratios must be > 0");
var totalRemaining = total;
var distributedTotal = new List<int>();
if (minimums == null)
{
minimums = ratios.Select(_ => 0).ToList();
}
foreach (var (ratio, minimum) in ratios.Zip(minimums, (a, b) => (a, b)))
{
var distributed = (totalRatio > 0)
? Math.Max(minimum, (int)Math.Ceiling(ratio * totalRemaining / (double)totalRatio))
: totalRemaining;
distributedTotal.Add(distributed);
totalRatio -= ratio;
totalRemaining -= distributed;
}
return distributedTotal;
}
}
}

View File

@ -22,6 +22,9 @@
<Compile Update="ConsoleExtensions.*.cs">
<DependentUpon>ConsoleExtensions.cs</DependentUpon>
</Compile>
<Compile Update="**/Table.*.cs">
<DependentUpon>**/Table.cs</DependentUpon>
</Compile>
</ItemGroup>