Don't limit tables and grids to markup text

Closes #13
This commit is contained in:
Patrik Svensson
2020-08-24 23:24:23 +02:00
committed by Patrik Svensson
parent effdecb1d4
commit 8a01b93aca
41 changed files with 472 additions and 193 deletions

View File

@ -0,0 +1,61 @@
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="IAlignable"/>.
/// </summary>
public static class AlignableExtensions
{
/// <summary>
/// Sets the alignment for an <see cref="IAlignable"/> object.
/// </summary>
/// <typeparam name="T">The alignable type.</typeparam>
/// <param name="alignable">The alignable object.</param>
/// <param name="alignment">The alignment.</param>
/// <returns>The same alignable object.</returns>
public static T WithAlignment<T>(this T alignable, Justify alignment)
where T : IAlignable
{
alignable.Alignment = alignment;
return alignable;
}
/// <summary>
/// Sets the <see cref="IAlignable"/> object to be left aligned.
/// </summary>
/// <typeparam name="T">The alignable type.</typeparam>
/// <param name="alignable">The alignable object.</param>
/// <returns>The same alignable object.</returns>
public static T LeftAligned<T>(this T alignable)
where T : IAlignable
{
alignable.Alignment = Justify.Left;
return alignable;
}
/// <summary>
/// Sets the <see cref="IAlignable"/> object to be centered.
/// </summary>
/// <typeparam name="T">The alignable type.</typeparam>
/// <param name="alignable">The alignable object.</param>
/// <returns>The same alignable object.</returns>
public static T Centered<T>(this T alignable)
where T : IAlignable
{
alignable.Alignment = Justify.Center;
return alignable;
}
/// <summary>
/// Sets the <see cref="IAlignable"/> object to be right aligned.
/// </summary>
/// <typeparam name="T">The alignable type.</typeparam>
/// <param name="alignable">The alignable object.</param>
/// <returns>The same alignable object.</returns>
public static T RightAligned<T>(this T alignable)
where T : IAlignable
{
alignable.Alignment = Justify.Right;
return alignable;
}
}
}

View File

@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents a border used by tables.
/// </summary>
public abstract class Border
{
private readonly Dictionary<BorderPart, string> _lookup;
private static readonly Dictionary<BorderKind, Border> _borders = new Dictionary<BorderKind, Border>
{
{ BorderKind.None, new NoBorder() },
{ BorderKind.Ascii, new AsciiBorder() },
{ BorderKind.Square, new SquareBorder() },
{ BorderKind.Rounded, new RoundedBorder() },
};
private static readonly Dictionary<BorderKind, BorderKind> _safeLookup = new Dictionary<BorderKind, BorderKind>
{
{ BorderKind.Rounded, BorderKind.Square },
};
/// <summary>
/// Initializes a new instance of the <see cref="Border"/> class.
/// </summary>
protected Border()
{
_lookup = Initialize();
}
/// <summary>
/// Gets a <see cref="Border"/> represented by the specified <see cref="BorderKind"/>.
/// </summary>
/// <param name="kind">The kind of border to get.</param>
/// <param name="safe">Whether or not to get a "safe" border that can be rendered in a legacy console.</param>
/// <returns>A <see cref="Border"/> instance representing the specified <see cref="BorderKind"/>.</returns>
public static Border GetBorder(BorderKind kind, bool safe)
{
if (safe && _safeLookup.TryGetValue(kind, out var safeKind))
{
kind = safeKind;
}
if (!_borders.TryGetValue(kind, out var border))
{
throw new InvalidOperationException("Unknown border kind");
}
return border;
}
private Dictionary<BorderPart, string> Initialize()
{
var lookup = new Dictionary<BorderPart, string>();
foreach (BorderPart? part in Enum.GetValues(typeof(BorderPart)))
{
if (part == null)
{
continue;
}
var text = GetBoxPart(part.Value);
if (text.Length > 1)
{
throw new InvalidOperationException("A box part cannot contain more than one character.");
}
lookup.Add(part.Value, GetBoxPart(part.Value));
}
return lookup;
}
/// <summary>
/// Gets the string representation of a specific border part.
/// </summary>
/// <param name="part">The part to get a string representation for.</param>
/// <param name="count">The number of repetitions.</param>
/// <returns>A string representation of the specified border part.</returns>
public string GetPart(BorderPart part, int count)
{
// TODO: This need some optimization...
return string.Join(string.Empty, Enumerable.Repeat(GetBoxPart(part)[0], count));
}
/// <summary>
/// Gets the string representation of a specific border part.
/// </summary>
/// <param name="part">The part to get a string representation for.</param>
/// <returns>A string representation of the specified border part.</returns>
public string GetPart(BorderPart part)
{
return _lookup[part].ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Gets the character representing the specified border part.
/// </summary>
/// <param name="part">The part to get the character representation for.</param>
/// <returns>A character representation of the specified border part.</returns>
protected abstract string GetBoxPart(BorderPart part);
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents an invisible border.
/// </summary>
public sealed class NoBorder : Border
{
/// <inheritdoc/>
protected override string GetBoxPart(BorderPart part)
{
return " ";
}
}
}

View File

@ -0,0 +1,37 @@
using System;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents a rounded border.
/// </summary>
public sealed class RoundedBorder : Border
{
/// <inheritdoc/>
protected override string GetBoxPart(BorderPart part)
{
return part switch
{
BorderPart.HeaderTopLeft => "╭",
BorderPart.HeaderTop => "─",
BorderPart.HeaderTopSeparator => "┬",
BorderPart.HeaderTopRight => "╮",
BorderPart.HeaderLeft => "│",
BorderPart.HeaderSeparator => "│",
BorderPart.HeaderRight => "│",
BorderPart.HeaderBottomLeft => "├",
BorderPart.HeaderBottom => "─",
BorderPart.HeaderBottomSeparator => "┼",
BorderPart.HeaderBottomRight => "┤",
BorderPart.CellLeft => "│",
BorderPart.CellSeparator => "│",
BorderPart.CellRight => "│",
BorderPart.FooterBottomLeft => "╰",
BorderPart.FooterBottom => "─",
BorderPart.FooterBottomSeparator => "┴",
BorderPart.FooterBottomRight => "╯",
_ => throw new InvalidOperationException("Unknown box part."),
};
}
}
}

View File

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

View File

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable grid.
/// </summary>
public sealed class Grid : Renderable
{
private readonly Table _table;
/// <summary>
/// Initializes a new instance of the <see cref="Grid"/> class.
/// </summary>
public Grid()
{
_table = new Table
{
Border = BorderKind.None,
ShowHeaders = false,
IsGrid = true,
PadRightCell = false,
};
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
return ((IRenderable)_table).Measure(context, maxWidth);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int width)
{
return ((IRenderable)_table).Render(context, width);
}
/// <summary>
/// Adds a column to the grid.
/// </summary>
public void AddColumn()
{
AddColumn(new GridColumn());
}
/// <summary>
/// Adds a column to the grid.
/// </summary>
/// <param name="column">The column to add.</param>
public void AddColumn(GridColumn column)
{
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
if (_table.RowCount > 0)
{
throw new InvalidOperationException("Cannot add new columns to grid with existing rows.");
}
// Only pad the most right cell if we've explicitly set a padding.
_table.PadRightCell = column.Padding != null;
_table.AddColumn(new TableColumn(string.Empty)
{
Width = column.Width,
NoWrap = column.NoWrap,
Padding = column.Padding ?? new Padding(0, 2),
Alignment = column.Alignment,
});
}
/// <summary>
/// Adds a column to the grid.
/// </summary>
/// <param name="count">The number of columns to add.</param>
public void AddColumns(int count)
{
for (var index = 0; index < count; index++)
{
AddColumn(new GridColumn());
}
}
/// <summary>
/// Adds a column to the grid.
/// </summary>
/// <param name="columns">The columns to add.</param>
public void AddColumns(params GridColumn[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
foreach (var column in columns)
{
AddColumn(column);
}
}
/// <summary>
/// Adds an empty row to the grid.
/// </summary>
public void AddEmptyRow()
{
var columns = new IRenderable[_table.ColumnCount];
Enumerable.Range(0, _table.ColumnCount).ForEach(index => columns[index] = Text.Empty);
AddRow(columns);
}
/// <summary>
/// Adds a new row to the grid.
/// </summary>
/// <param name="columns">The columns to add.</param>
public void AddRow(params IRenderable[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
if (columns.Length < _table.ColumnCount)
{
throw new InvalidOperationException("The number of row columns are less than the number of grid columns.");
}
if (columns.Length > _table.ColumnCount)
{
throw new InvalidOperationException("The number of row columns are greater than the number of grid columns.");
}
_table.AddRow(columns);
}
}
}

View File

@ -0,0 +1,30 @@
namespace Spectre.Console
{
/// <summary>
/// Represents a grid column.
/// </summary>
public sealed class GridColumn : IAlignable
{
/// <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; }
/// <summary>
/// Gets or sets the padding of the column.
/// </summary>
public Padding? Padding { get; set; }
/// <summary>
/// Gets or sets the alignment of the column.
/// </summary>
public Justify? Alignment { get; set; }
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Linq;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="Grid"/>.
/// </summary>
public static class GridExtensions
{
/// <summary>
/// Adds a new row to the grid.
/// </summary>
/// <param name="grid">The grid to add the row to.</param>
/// <param name="columns">The columns to add.</param>
public static void AddRow(this Grid grid, params string[] columns)
{
if (grid is null)
{
throw new ArgumentNullException(nameof(grid));
}
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
grid.AddRow(columns.Select(column => new Markup(column)).ToArray());
}
}
}

View File

@ -0,0 +1,13 @@
namespace Spectre.Console
{
/// <summary>
/// Represents something that is alignable.
/// </summary>
public interface IAlignable
{
/// <summary>
/// Gets or sets the alignment.
/// </summary>
Justify? Alignment { get; set; }
}
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents something that can be rendered to the console.
/// </summary>
public interface IRenderable
{
/// <summary>
/// Measures the renderable object.
/// </summary>
/// <param name="context">The render context.</param>
/// <param name="maxWidth">The maximum allowed width.</param>
/// <returns>The minimum and maximum width of the object.</returns>
Measurement Measure(RenderContext context, int maxWidth);
/// <summary>
/// Renders the object.
/// </summary>
/// <param name="context">The render context.</param>
/// <param name="maxWidth">The maximum allowed width.</param>
/// <returns>A collection of segments.</returns>
IEnumerable<Segment> Render(RenderContext context, int maxWidth);
}
}

View File

@ -0,0 +1,23 @@
namespace Spectre.Console
{
/// <summary>
/// Represents text justification.
/// </summary>
public enum Justify
{
/// <summary>
/// Left aligned.
/// </summary>
Left = 0,
/// <summary>
/// Right aligned.
/// </summary>
Right = 1,
/// <summary>
/// Centered.
/// </summary>
Center = 2,
}
}

View File

@ -0,0 +1,43 @@
using System.Collections.Generic;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable piece of markup text.
/// </summary>
public sealed class Markup : Renderable, IAlignable
{
private readonly Text _text;
/// <inheritdoc/>
public Justify? Alignment
{
get => _text.Alignment;
set => _text.Alignment = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="Markup"/> class.
/// </summary>
/// <param name="text">The markup text.</param>
/// <param name="style">The style of the text.</param>
public Markup(string text, Style? style = null)
{
_text = MarkupParser.Parse(text, style);
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
return ((IRenderable)_text).Measure(context, maxWidth);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
return ((IRenderable)_text).Render(context, maxWidth);
}
}
}

View File

@ -0,0 +1,77 @@
using System;
namespace Spectre.Console.Rendering
{
/// <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

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

View File

@ -0,0 +1,145 @@
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Rendering;
using SpectreBorder = Spectre.Console.Rendering.Border;
namespace Spectre.Console
{
/// <summary>
/// A renderable panel.
/// </summary>
public sealed class Panel : Renderable
{
private const int EdgeWidth = 2;
private readonly IRenderable _child;
/// <summary>
/// Gets or sets a value indicating whether or not to use
/// a "safe" border on legacy consoles that might not be able
/// to render non-ASCII characters. Defaults to <c>true</c>.
/// </summary>
public bool SafeBorder { get; set; } = true;
/// <summary>
/// Gets or sets the kind of border to use.
/// </summary>
public BorderKind Border { get; set; } = BorderKind.Square;
/// <summary>
/// Gets or sets the alignment of the panel contents.
/// </summary>
public Justify? Alignment { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not the panel should
/// fit the available space. If <c>false</c>, the panel width will be
/// auto calculated. Defaults to <c>false</c>.
/// </summary>
public bool Expand { get; set; }
/// <summary>
/// Gets or sets the padding.
/// </summary>
public Padding Padding { get; set; } = new Padding(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="Panel"/> class.
/// </summary>
/// <param name="text">The panel content.</param>
public Panel(string text)
: this(new Markup(text))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Panel"/> class.
/// </summary>
/// <param name="content">The panel content.</param>
public Panel(IRenderable content)
{
_child = content ?? throw new System.ArgumentNullException(nameof(content));
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
var childWidth = _child.Measure(context, maxWidth);
return new Measurement(
childWidth.Min + 2 + Padding.GetHorizontalPadding(),
childWidth.Max + 2 + Padding.GetHorizontalPadding());
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var border = SpectreBorder.GetBorder(Border, (context.LegacyConsole || !context.Unicode) && SafeBorder);
var paddingWidth = Padding.GetHorizontalPadding();
var childWidth = maxWidth - EdgeWidth - paddingWidth;
if (!Expand)
{
var measurement = _child.Measure(context, maxWidth - EdgeWidth - paddingWidth);
childWidth = measurement.Max;
}
var panelWidth = childWidth + paddingWidth;
// Panel top
var result = new List<Segment>
{
new Segment(border.GetPart(BorderPart.HeaderTopLeft)),
new Segment(border.GetPart(BorderPart.HeaderTop, panelWidth)),
new Segment(border.GetPart(BorderPart.HeaderTopRight)),
Segment.LineBreak,
};
// Render the child.
var childContext = context.WithJustification(Alignment);
var childSegments = _child.Render(childContext, childWidth);
// Split the child segments into lines.
foreach (var line in Segment.SplitLines(childSegments, panelWidth))
{
result.Add(new Segment(border.GetPart(BorderPart.CellLeft)));
// Left padding
if (Padding.Left > 0)
{
result.Add(new Segment(new string(' ', Padding.Left)));
}
var content = new List<Segment>();
content.AddRange(line);
// Do we need to pad the panel?
var length = line.Sum(segment => segment.CellLength(context.Encoding));
if (length < childWidth)
{
var diff = childWidth - length;
content.Add(new Segment(new string(' ', diff)));
}
result.AddRange(content);
// Right padding
if (Padding.Right > 0)
{
result.Add(new Segment(new string(' ', Padding.Right)));
}
result.Add(new Segment(border.GetPart(BorderPart.CellRight)));
result.Add(Segment.LineBreak);
}
// Panel bottom
result.Add(new Segment(border.GetPart(BorderPart.FooterBottomLeft)));
result.Add(new Segment(border.GetPart(BorderPart.FooterBottom, panelWidth)));
result.Add(new Segment(border.GetPart(BorderPart.FooterBottomRight)));
result.Add(Segment.LineBreak);
return result;
}
}
}

View File

@ -0,0 +1,54 @@
using System.Text;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents a render context.
/// </summary>
public sealed class RenderContext
{
/// <summary>
/// Gets the console's output encoding.
/// </summary>
public Encoding Encoding { get; }
/// <summary>
/// Gets a value indicating whether or not this a legacy console (i.e. cmd.exe).
/// </summary>
public bool LegacyConsole { get; }
/// <summary>
/// Gets a value indicating whether or not unicode is supported.
/// </summary>
public bool Unicode { get; }
/// <summary>
/// Gets the current justification.
/// </summary>
public Justify? Justification { get; }
/// <summary>
/// Initializes a new instance of the <see cref="RenderContext"/> class.
/// </summary>
/// <param name="encoding">The console's output encoding.</param>
/// <param name="legacyConsole">A value indicating whether or not this a legacy console (i.e. cmd.exe).</param>
/// <param name="justification">The justification to use when rendering.</param>
public RenderContext(Encoding encoding, bool legacyConsole, Justify? justification = null)
{
Encoding = encoding ?? throw new System.ArgumentNullException(nameof(encoding));
LegacyConsole = legacyConsole;
Justification = justification;
Unicode = Encoding == Encoding.UTF8 || Encoding == Encoding.Unicode;
}
/// <summary>
/// Creates a new context with the specified justification.
/// </summary>
/// <param name="justification">The justification.</param>
/// <returns>A new <see cref="RenderContext"/> instance with the specified justification.</returns>
public RenderContext WithJustification(Justify? justification)
{
return new RenderContext(Encoding, LegacyConsole, justification);
}
}
}

View File

@ -0,0 +1,41 @@
using System.Collections.Generic;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Base class for a renderable object implementing <see cref="IRenderable"/>.
/// </summary>
public abstract class Renderable : IRenderable
{
/// <inheritdoc/>
Measurement IRenderable.Measure(RenderContext context, int maxWidth)
{
return Measure(context, maxWidth);
}
/// <inheritdoc/>
IEnumerable<Segment> IRenderable.Render(RenderContext context, int maxWidth)
{
return Render(context, maxWidth);
}
/// <summary>
/// Measures the renderable object.
/// </summary>
/// <param name="context">The render context.</param>
/// <param name="maxWidth">The maximum allowed width.</param>
/// <returns>The minimum and maximum width of the object.</returns>
protected virtual Measurement Measure(RenderContext context, int maxWidth)
{
return new Measurement(maxWidth, maxWidth);
}
/// <summary>
/// Renders the object.
/// </summary>
/// <param name="context">The render context.</param>
/// <param name="maxWidth">The maximum allowed width.</param>
/// <returns>A collection of segments.</returns>
protected abstract IEnumerable<Segment> Render(RenderContext context, int maxWidth);
}
}

View File

@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Spectre.Console.Internal;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents a renderable segment.
/// </summary>
[DebuggerDisplay("{Text,nq}")]
public class Segment
{
/// <summary>
/// Gets the segment text.
/// </summary>
public string Text { get; }
/// <summary>
/// Gets a value indicating whether or not this is an expicit line break
/// that should be preserved.
/// </summary>
public bool IsLineBreak { get; }
/// <summary>
/// Gets a value indicating whether or not this is a whitespace
/// that should be preserved but not taken into account when
/// layouting text.
/// </summary>
public bool IsWhiteSpace { get; }
/// <summary>
/// Gets the segment style.
/// </summary>
public Style Style { get; }
/// <summary>
/// Gets a segment representing a line break.
/// </summary>
public static Segment LineBreak { get; } = new Segment(Environment.NewLine, Style.Plain, true);
/// <summary>
/// Gets an empty segment.
/// </summary>
public static Segment Empty { get; } = new Segment(string.Empty, Style.Plain);
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="text">The segment text.</param>
public Segment(string text)
: this(text, Style.Plain)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="text">The segment text.</param>
/// <param name="style">The segment style.</param>
public Segment(string text, Style style)
: this(text, style, false)
{
}
private Segment(string text, Style style, bool lineBreak)
{
if (text is null)
{
throw new ArgumentNullException(nameof(text));
}
Text = text.NormalizeLineEndings();
Style = style;
IsLineBreak = lineBreak;
IsWhiteSpace = string.IsNullOrWhiteSpace(text);
}
/// <summary>
/// Gets the number of cells that this segment
/// occupies in the console.
/// </summary>
/// <param name="encoding">The encoding to use.</param>
/// <returns>The number of cells that this segment occupies in the console.</returns>
public int CellLength(Encoding encoding)
{
return Text.CellLength(encoding);
}
/// <summary>
/// Returns a new segment without any trailing line endings.
/// </summary>
/// <returns>A new segment without any trailing line endings.</returns>
public Segment StripLineEndings()
{
return new Segment(Text.TrimEnd('\n').TrimEnd('\r'), Style);
}
/// <summary>
/// Splits the segment at the offset.
/// </summary>
/// <param name="offset">The offset where to split the segment.</param>
/// <returns>One or two new segments representing the split.</returns>
public (Segment First, Segment? Second) Split(int offset)
{
if (offset < 0)
{
return (this, null);
}
if (offset >= Text.Length)
{
return (this, null);
}
return (
new Segment(Text.Substring(0, offset), Style),
new Segment(Text.Substring(offset, Text.Length - offset), Style));
}
/// <summary>
/// Splits the provided segments into lines.
/// </summary>
/// <param name="segments">The segments to split.</param>
/// <returns>A collection of lines.</returns>
public static List<SegmentLine> SplitLines(IEnumerable<Segment> segments)
{
return SplitLines(segments, int.MaxValue);
}
/// <summary>
/// Splits the provided segments into lines with a maximum width.
/// </summary>
/// <param name="segments">The segments to split into lines.</param>
/// <param name="maxWidth">The maximum width.</param>
/// <returns>A list of lines.</returns>
public static List<SegmentLine> SplitLines(IEnumerable<Segment> segments, int maxWidth)
{
if (segments is null)
{
throw new ArgumentNullException(nameof(segments));
}
var lines = new List<SegmentLine>();
var line = new SegmentLine();
var stack = new Stack<Segment>(segments.Reverse());
while (stack.Count > 0)
{
var segment = stack.Pop();
if (line.Width + segment.Text.Length > maxWidth)
{
var diff = -(maxWidth - (line.Width + segment.Text.Length));
var offset = segment.Text.Length - diff;
var (first, second) = segment.Split(offset);
line.Add(first);
lines.Add(line);
line = new SegmentLine();
if (second != null)
{
stack.Push(second);
}
continue;
}
if (segment.Text.Contains("\n"))
{
if (segment.Text == "\n")
{
if (line.Width > 0 || segment.IsLineBreak)
{
lines.Add(line);
line = new SegmentLine();
}
continue;
}
var text = segment.Text;
while (text != null)
{
var parts = text.SplitLines();
if (parts.Length > 0)
{
if (parts[0].Length > 0)
{
line.Add(new Segment(parts[0], segment.Style));
}
}
if (parts.Length > 1)
{
if (line.Width > 0)
{
lines.Add(line);
line = new SegmentLine();
}
text = string.Concat(parts.Skip(1).Take(parts.Length - 1));
}
else
{
text = null;
}
}
}
else
{
line.Add(segment);
}
}
if (line.Count > 0)
{
lines.Add(line);
}
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,38 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
namespace Spectre.Console.Rendering
{
/// <summary>
/// Represents a collection of segments.
/// </summary>
[SuppressMessage("Naming", "CA1710:Identifiers should have correct suffix")]
public sealed class SegmentLine : List<Segment>
{
/// <summary>
/// Gets the width of the line.
/// </summary>
public int Width => this.Sum(line => line.Text.Length);
/// <summary>
/// Gets the cell width of the segment line.
/// </summary>
/// <param name="encoding">The encoding to use.</param>
/// <returns>The cell width of the segment line.</returns>
public int CellWidth(Encoding encoding)
{
return this.Sum(line => line.CellLength(encoding));
}
/// <summary>
/// Preprends a segment to the line.
/// </summary>
/// <param name="segment">The segment to prepend.</param>
public void Prepend(Segment segment)
{
Insert(0, segment);
}
}
}

View File

@ -0,0 +1,25 @@
using System.Collections;
using System.Collections.Generic;
namespace Spectre.Console.Rendering
{
internal sealed class SegmentLineEnumerator : IEnumerable<Segment>
{
private readonly List<SegmentLine> _lines;
public SegmentLineEnumerator(List<SegmentLine> lines)
{
_lines = lines;
}
public IEnumerator<Segment> GetEnumerator()
{
return new SegmentLineIterator(_lines);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
}

View File

@ -0,0 +1,99 @@
using System.Collections;
using System.Collections.Generic;
namespace Spectre.Console.Rendering
{
internal sealed class SegmentLineIterator : IEnumerator<Segment>
{
private readonly List<SegmentLine> _lines;
private int _currentLine;
private int _currentIndex;
private bool _lineBreakEmitted;
public Segment Current { get; private set; }
object? IEnumerator.Current => Current;
public SegmentLineIterator(List<SegmentLine> lines)
{
_currentLine = 0;
_currentIndex = -1;
_lines = lines;
Current = Segment.Empty;
}
public void Dispose()
{
}
public bool MoveNext()
{
if (_currentLine > _lines.Count - 1)
{
return false;
}
_currentIndex += 1;
// Did we go past the end of the line?
if (_currentIndex > _lines[_currentLine].Count - 1)
{
// We haven't just emitted a line break?
if (!_lineBreakEmitted)
{
// Got any more lines?
if (_currentIndex + 1 > _lines[_currentLine].Count - 1)
{
// Only emit a line break if the next one isn't a line break.
if ((_currentLine + 1 <= _lines.Count - 1)
&& _lines[_currentLine + 1].Count > 0
&& !_lines[_currentLine + 1][0].IsLineBreak)
{
_lineBreakEmitted = true;
Current = Segment.LineBreak;
return true;
}
}
}
// Increase the line and reset the index.
_currentLine += 1;
_currentIndex = 0;
_lineBreakEmitted = false;
// No more lines?
if (_currentLine > _lines.Count - 1)
{
return false;
}
// Nothing on the line?
while (_currentIndex > _lines[_currentLine].Count - 1)
{
_currentLine += 1;
_currentIndex = 0;
if (_currentLine > _lines.Count - 1)
{
return false;
}
}
}
// Reset the flag
_lineBreakEmitted = false;
Current = _lines[_currentLine][_currentIndex];
return true;
}
public void Reset()
{
_currentLine = 0;
_currentIndex = -1;
Current = Segment.Empty;
}
}
}

View File

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a table.
/// </summary>
public sealed partial class Table
{
private const int EdgeCount = 2;
// 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));
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 (int Min, int Max) MeasureColumn(TableColumn column, RenderContext options, int maxWidth)
{
var padding = column.Padding.GetHorizontalPadding();
// Predetermined width?
if (column.Width != null)
{
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>();
// Include columns in measurement
var measure = column.Text.Measure(options, maxWidth);
minWidths.Add(measure.Min);
maxWidths.Add(measure.Max);
foreach (var row in rows)
{
measure = row.Measure(options, maxWidth);
minWidths.Add(measure.Min);
maxWidths.Add(measure.Max);
}
return (minWidths.Count > 0 ? minWidths.Max() : padding,
maxWidths.Count > 0 ? maxWidths.Max() : maxWidth);
}
private int GetExtraWidth(bool includePadding)
{
var separators = _columns.Count - 1;
var padding = includePadding ? _columns.Select(x => x.Padding.GetHorizontalPadding()).Sum() : 0;
return separators + EdgeCount + padding;
}
}
}

View File

@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
using SpectreBorder = Spectre.Console.Rendering.Border;
namespace Spectre.Console
{
/// <summary>
/// A renderable table.
/// </summary>
public sealed partial class Table : Renderable
{
private readonly List<TableColumn> _columns;
private readonly List<List<IRenderable>> _rows;
/// <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; }
/// <summary>
/// Gets or sets the width of the table.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not to use
/// a "safe" border on legacy consoles that might not be able
/// to render non-ASCII characters. Defaults to <c>true</c>.
/// </summary>
public bool SafeBorder { get; set; } = true;
// 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<List<IRenderable>>();
}
/// <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));
}
if (_rows.Count > 0)
{
throw new InvalidOperationException("Cannot add new columns to table with existing rows.");
}
_columns.Add(column);
}
/// <summary>
/// Adds multiple columns to the table.
/// </summary>
/// <param name="columns">The columns to add.</param>
public void AddColumns(params TableColumn[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
foreach (var column in columns)
{
AddColumn(column);
}
}
/// <summary>
/// Adds an empty row to the table.
/// </summary>
public void AddEmptyRow()
{
var columns = new IRenderable[ColumnCount];
Enumerable.Range(0, ColumnCount).ForEach(index => columns[index] = Text.Empty);
AddRow(columns);
}
/// <summary>
/// Adds a row to the table.
/// </summary>
/// <param name="columns">The row columns to add.</param>
public void AddRow(params IRenderable[] columns)
{
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
if (columns.Length < _columns.Count)
{
throw new InvalidOperationException("The number of row columns are less than the number of table columns.");
}
if (columns.Length > _columns.Count)
{
throw new InvalidOperationException("The number of row columns are greater than the number of table columns.");
}
_rows.Add(columns.ToList());
}
/// <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 = SpectreBorder.GetBorder(Border, (context.LegacyConsole || !context.Unicode) && SafeBorder);
var tableWidth = maxWidth;
var showBorder = Border != BorderKind.None;
var hideBorder = Border == BorderKind.None;
var hasRows = _rows.Count > 0;
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);
var rows = new List<List<IRenderable>>();
if (ShowHeaders)
{
// Add columns to top of rows
rows.Add(new List<IRenderable>(_columns.Select(c => c.Text)));
}
// Add rows.
rows.AddRange(_rows);
// Iterate all rows
var result = new List<Segment>();
foreach (var (index, firstRow, lastRow, row) in rows.Enumerate())
{
var cellHeight = 1;
// Get the list of cells for the row 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(cell.Render(childContext, rowWidth));
cellHeight = Math.Max(cellHeight, lines.Count);
cells.Add(lines);
}
// Show top of header?
if (firstRow && showBorder)
{
result.Add(new Segment(border.GetPart(BorderPart.HeaderTopLeft)));
foreach (var (columnIndex, _, lastColumn, columnWidth) in columnWidths.Enumerate())
{
var padding = _columns[columnIndex].Padding;
result.Add(new Segment(border.GetPart(BorderPart.HeaderTop, padding.Left))); // Left padding
result.Add(new Segment(border.GetPart(BorderPart.HeaderTop, columnWidth)));
result.Add(new Segment(border.GetPart(BorderPart.HeaderTop, padding.Right))); // Right padding
if (!lastColumn)
{
result.Add(new Segment(border.GetPart(BorderPart.HeaderTopSeparator)));
}
}
result.Add(new Segment(border.GetPart(BorderPart.HeaderTopRight)));
result.Add(Segment.LineBreak);
}
// Iterate through each cell row
foreach (var cellRowIndex in Enumerable.Range(0, cellHeight))
{
// Make cells the same shape
cells = Segment.MakeSameHeight(cellHeight, cells);
foreach (var (cellIndex, firstCell, lastCell, cell) in cells.Enumerate())
{
if (firstCell && showBorder)
{
// Show left column edge
result.Add(new Segment(border.GetPart(BorderPart.CellLeft)));
}
// Pad column on left side.
if (showBorder || IsGrid)
{
var leftPadding = _columns[cellIndex].Padding.Left;
if (leftPadding > 0)
{
result.Add(new Segment(new string(' ', leftPadding)));
}
}
// Add content
result.AddRange(cell[cellRowIndex]);
// Pad cell content right
var length = cell[cellRowIndex].Sum(segment => segment.CellLength(context.Encoding));
if (length < columnWidths[cellIndex])
{
result.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.Right;
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)));
}
else if (showBorder)
{
// Add column separator
result.Add(new Segment(border.GetPart(BorderPart.CellSeparator)));
}
}
result.Add(Segment.LineBreak);
}
// Show header separator?
if (firstRow && showBorder && ShowHeaders && hasRows)
{
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottomLeft)));
foreach (var (columnIndex, first, lastColumn, columnWidth) in columnWidths.Enumerate())
{
var padding = _columns[columnIndex].Padding;
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottom, padding.Left))); // Left padding
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottom, columnWidth)));
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottom, padding.Right))); // Right padding
if (!lastColumn)
{
result.Add(new Segment(border.GetPart(BorderPart.HeaderBottomSeparator)));
}
}
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)));
foreach (var (columnIndex, first, lastColumn, columnWidth) in columnWidths.Enumerate())
{
var padding = _columns[columnIndex].Padding;
result.Add(new Segment(border.GetPart(BorderPart.FooterBottom, padding.Left))); // Left padding
result.Add(new Segment(border.GetPart(BorderPart.FooterBottom, columnWidth)));
result.Add(new Segment(border.GetPart(BorderPart.FooterBottom, padding.Right))); // Right padding
if (!lastColumn)
{
result.Add(new Segment(border.GetPart(BorderPart.FooterBottomSeparator)));
}
}
result.Add(new Segment(border.GetPart(BorderPart.FooterBottomRight)));
result.Add(Segment.LineBreak);
}
}
return result;
}
private bool ShouldExpand()
{
return Expand || Width != null;
}
}
}

View File

@ -0,0 +1,60 @@
using System;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a table column.
/// </summary>
public sealed class TableColumn : IAlignable
{
/// <summary>
/// Gets the text associated with the column.
/// </summary>
public IRenderable 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 padding of the column.
/// </summary>
public Padding Padding { 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>
/// Gets or sets the alignment of the column.
/// </summary>
public Justify? Alignment { 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)
: this(new Markup(text))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TableColumn"/> class.
/// </summary>
/// <param name="renderable">The <see cref="IRenderable"/> instance to use as the table column.</param>
public TableColumn(IRenderable renderable)
{
Text = renderable ?? throw new ArgumentNullException(nameof(renderable));
Width = null;
Padding = new Padding(1, 1);
NoWrap = false;
Alignment = null;
}
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Linq;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="Table"/>.
/// </summary>
public static class TableExtensions
{
/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="table">The table to add the column to.</param>
/// <param name="column">The column to add.</param>
/// <returns>The added <see cref="TableColumn"/> instance.</returns>
public static TableColumn AddColumn(this Table table, string column)
{
if (table is null)
{
throw new ArgumentNullException(nameof(table));
}
if (column is null)
{
throw new ArgumentNullException(nameof(column));
}
var tableColumn = new TableColumn(column);
table.AddColumn(tableColumn);
return tableColumn;
}
/// <summary>
/// Adds multiple columns to the table.
/// </summary>
/// <param name="table">The table to add the columns to.</param>
/// <param name="columns">The columns to add.</param>
public static void AddColumns(this Table table, params string[] columns)
{
if (table is null)
{
throw new ArgumentNullException(nameof(table));
}
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
foreach (var column in columns)
{
AddColumn(table, column);
}
}
/// <summary>
/// Adds a row to the table.
/// </summary>
/// <param name="table">The table to add the row to.</param>
/// <param name="columns">The row columns to add.</param>
public static void AddRow(this Table table, params string[] columns)
{
if (table is null)
{
throw new ArgumentNullException(nameof(table));
}
if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}
table.AddRow(columns.Select(column => new Markup(column)).ToArray());
}
}
}

View File

@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable piece of text.
/// </summary>
[DebuggerDisplay("{_text,nq}")]
[SuppressMessage("Naming", "CA1724:Type names should not match namespaces")]
public sealed class Text : Renderable, IAlignable
{
private readonly List<SegmentLine> _lines;
/// <summary>
/// Gets an empty <see cref="Text"/> instance.
/// </summary>
public static Text Empty { get; } = new Text(string.Empty);
/// <summary>
/// Gets or sets the text alignment.
/// </summary>
public Justify? Alignment { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="Text"/> class.
/// </summary>
public Text()
{
_lines = new List<SegmentLine>();
}
/// <summary>
/// Initializes a new instance of the <see cref="Text"/> class.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="style">The style of the text.</param>
public Text(string text, Style? style = null)
: this()
{
if (text is null)
{
throw new ArgumentNullException(nameof(text));
}
Append(text, style);
}
/// <summary>
/// Creates a <see cref="Text"/> instance representing
/// the specified markup text.
/// </summary>
/// <param name="text">The markup text.</param>
/// <param name="style">The text style.</param>
/// <returns>a <see cref="Text"/> instance representing the specified markup text.</returns>
public static Text Markup(string text, Style? style = null)
{
var result = MarkupParser.Parse(text, style ?? Style.Plain);
return result;
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
if (_lines.Count == 0)
{
return new Measurement(0, 0);
}
var min = _lines.Max(line => line.Max(segment => segment.CellLength(context.Encoding)));
var max = _lines.Max(x => x.CellWidth(context.Encoding));
return new Measurement(min, max);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (_lines.Count == 0)
{
return Array.Empty<Segment>();
}
var lines = SplitLines(context, maxWidth);
// Justify lines
var justification = context.Justification ?? Alignment ?? Justify.Left;
foreach (var (_, _, last, line) in lines.Enumerate())
{
var length = line.Sum(l => l.StripLineEndings().CellLength(context.Encoding));
if (length < maxWidth)
{
// Justify right side
if (justification == Justify.Right)
{
var diff = maxWidth - length;
line.Prepend(new Segment(new string(' ', diff)));
}
else if (justification == Justify.Center)
{
// Left side.
var diff = (maxWidth - length) / 2;
line.Prepend(new Segment(new string(' ', diff)));
// Right side
line.Add(new Segment(new string(' ', diff)));
var remainder = (maxWidth - length) % 2;
if (remainder != 0)
{
line.Add(new Segment(new string(' ', remainder)));
}
}
}
}
return new SegmentLineEnumerator(lines);
}
/// <summary>
/// Appends a piece of text.
/// </summary>
/// <param name="text">The text to append.</param>
/// <param name="style">The style of the appended text.</param>
public void Append(string text, Style? style = null)
{
if (text is null)
{
throw new ArgumentNullException(nameof(text));
}
foreach (var (_, first, last, part) in text.SplitLines().Enumerate())
{
var current = part;
if (first)
{
var line = _lines.LastOrDefault();
if (line == null)
{
_lines.Add(new SegmentLine());
line = _lines.Last();
}
if (string.IsNullOrEmpty(current))
{
line.Add(Segment.Empty);
}
else
{
foreach (var span in current.SplitWords())
{
line.Add(new Segment(span, style ?? Style.Plain));
}
}
}
else
{
var line = new SegmentLine();
if (string.IsNullOrEmpty(current))
{
line.Add(Segment.Empty);
}
else
{
foreach (var span in current.SplitWords())
{
line.Add(new Segment(span, style ?? Style.Plain));
}
}
_lines.Add(line);
}
}
}
private List<SegmentLine> Clone()
{
var result = new List<SegmentLine>();
foreach (var line in _lines)
{
var newLine = new SegmentLine();
foreach (var segment in line)
{
newLine.Add(segment);
}
result.Add(newLine);
}
return result;
}
private List<SegmentLine> SplitLines(RenderContext context, int maxWidth)
{
if (_lines.Max(x => x.CellWidth(context.Encoding)) <= maxWidth)
{
return Clone();
}
var lines = new List<SegmentLine>();
var line = new SegmentLine();
var newLine = true;
using (var iterator = new SegmentLineIterator(_lines))
{
while (iterator.MoveNext())
{
var current = iterator.Current;
if (current == null)
{
throw new InvalidOperationException("Iterator returned empty segment.");
}
if (newLine && current.IsWhiteSpace && !current.IsLineBreak)
{
newLine = false;
continue;
}
newLine = false;
if (current.IsLineBreak)
{
line.Add(current);
lines.Add(line);
line = new SegmentLine();
newLine = true;
continue;
}
var length = current.CellLength(context.Encoding);
if (line.CellWidth(context.Encoding) + length > maxWidth)
{
line.Add(Segment.Empty);
lines.Add(line);
line = new SegmentLine();
newLine = true;
}
if (newLine && current.IsWhiteSpace)
{
continue;
}
newLine = false;
line.Add(current);
}
}
// Flush remaining.
if (line.Count > 0)
{
lines.Add(line);
}
return lines;
}
}
}