Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add table/grid column width in fixed, sizeToConent (auto) and star (proportional) #707

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/Spectre.Console/ColumnWidth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
namespace Spectre.Console;

/// <summary>
/// Column width definition.
/// The size mode defines how the column width will be interpreted:
/// <list type="bullet">
/// <item>
/// <term><see cref="SizeMode.SizeToContent">SizeToContent (Auto)</see></term>
/// <description><see cref="Value" /> value is ignored and width will auto-size to content.</description>
/// </item>
/// <item>
/// <term><see cref="SizeMode.Fixed">Fixed</see></term>
/// <description><see cref="Value" /> value is interpreted as integer, fixed size.</description>
/// </item>
/// <item>
/// <term><see cref="SizeMode.Proportional">Star (*)</see></term>
/// <description><see cref="Value" /> value is interpreted as double and means proportional sizing. If the width value is <see langword="null"/> 1 is implied</description>
/// </item>
/// </list>
/// If mixed <see cref="SizeMode.SizeToContent" /> and <see cref="SizeMode.Fixed" /> widths with <see cref="SizeMode.Proportional" /> (proportional) widths:
/// The <see cref="SizeMode.Proportional" /> columns are apportioned to the remainder after the <see cref="SizeMode.SizeToContent" /> and
/// <see cref="SizeMode.Fixed" /> widths have been calculated.
/// </summary>
public class ColumnWidth : IEquatable<ColumnWidth?>
{
/// <summary>
/// Gets the size mode, to define
/// how the width <see cref="Value"/> is interpreted.
/// </summary>
public SizeMode SizeMode
{
get;
}

/// <summary>
/// Gets the width value.
/// </summary>
public double Value
{
get;
}

private ColumnWidth(SizeMode sizeMode, double value)
{
SizeMode = sizeMode;
Value = value;
}

/// <summary>
/// Implictly converts an <see cref="int"/> to a fixed column width.
/// </summary>
/// <param name="size">The column fixed width.</param>
public static implicit operator ColumnWidth(int? size) => size == null ? SizeToContent() : Fixed(size.Value);

/// <summary>
/// Determines whether the specified objects are equal.
/// </summary>
/// <param name="left">The first object to compare.</param>
/// <param name="right">The second object to compare.</param>
/// <returns><see langword="true"/> if the specified objects are equal; otherwise, <see langword="false"/>.</returns>
public static bool operator ==(ColumnWidth? left, ColumnWidth? right) => EqualityComparer<ColumnWidth>.Default.Equals(left, right);

/// <summary>
/// Determines whether the specified objects are not equal.
/// </summary>
/// <param name="left">The first object to compare.</param>
/// <param name="right">The second object to compare.</param>
/// <returns><see langword="true"/> if the specified objects are not equal; otherwise, <see langword="false"/>.</returns>
public static bool operator !=(ColumnWidth? left, ColumnWidth? right) => !(left == right);

/// <inheritdoc/>
public override bool Equals(object? obj) => Equals(obj as ColumnWidth);

/// <inheritdoc/>
public bool Equals(ColumnWidth? other)
=> other != null &&
SizeMode == other.SizeMode &&
Value == other.Value;

/// <inheritdoc/>
public override int GetHashCode()
#if NETSTANDARD2_0
=> new { SizeMode, Value }.GetHashCode();
#else
=> HashCode.Combine(SizeMode, Value);
#endif

/// <summary>
/// Creates a <see cref="ColumnWidth"/> with a fix size.
/// </summary>
/// <param name="size">The column fixed width.</param>
/// <returns>A fix size width.</returns>
public static ColumnWidth Fixed(int size)
{
if (size < 0.0)
{
throw new ArgumentException("Fixed size cannot be negative", nameof(size));
}

return new ColumnWidth(SizeMode.Fixed, size);
}

/// <summary>
/// Creates a proportional weighted <see cref="ColumnWidth"/>.
/// </summary>
/// <param name="weight">The column weighted width.</param>
/// <returns>A proportional weighted width.</returns>
public static ColumnWidth Proportional(double weight = 1.0)
{
if (weight < 0.0)
{
throw new ArgumentException("Weight cannot be negative", nameof(weight));
}

return new ColumnWidth(SizeMode.Proportional, weight);
}

/// <summary>
/// Creates a <see cref="ColumnWidth"/> to auto size to content.
/// </summary>
/// <returns>An auto size width.</returns>
public static ColumnWidth SizeToContent() => new ColumnWidth(SizeMode.SizeToContent, 0.0);
}
69 changes: 68 additions & 1 deletion src/Spectre.Console/Extensions/ColumnExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,42 @@ public static T NoWrap<T>(this T obj)
return obj;
}

/// <summary>
/// Sets the width of the column.
/// </summary>
/// <typeparam name="T">An object implementing <see cref="IColumn"/>.</typeparam>
/// <param name="obj">The column.</param>
/// <param name="size">The column width.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
/// <exception cref="ArgumentNullException">if <paramref name="obj"/> is null.</exception>
/// <exception cref="ArgumentException">if <paramref name="size"/> is negative.</exception>
public static T Width<T>(this T obj, int? size)
where T : class, IColumn
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

if (size < 0)
{
throw new ArgumentException("Fixed width cannot be negative", nameof(size));
}

// Implicitly convert int? to ColumnWidth
obj.Width = size;
return obj;
}

/// <summary>
/// Sets the width of the column.
/// </summary>
/// <typeparam name="T">An object implementing <see cref="IColumn"/>.</typeparam>
/// <param name="obj">The column.</param>
/// <param name="width">The column width.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static T Width<T>(this T obj, int? width)
/// <exception cref="ArgumentNullException">if <paramref name="obj"/> is null.</exception>
public static T Width<T>(this T obj, ColumnWidth width)
where T : class, IColumn
{
if (obj is null)
Expand All @@ -41,4 +69,43 @@ public static T Width<T>(this T obj, int? width)
obj.Width = width;
return obj;
}

/// <summary>
/// Checks column width size mode.
/// </summary>
/// <param name="obj">The column.</param>
/// <returns><see langword="true"/> if column width is proportional.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="obj"/> is null.</exception>
public static bool IsProportionalWidth(this IColumn obj)
=> obj switch
{
null => throw new ArgumentNullException(nameof(obj)),
_ => obj.Width.SizeMode == SizeMode.Proportional,
};

/// <summary>
/// Checks column width size mode.
/// </summary>
/// <param name="obj">The column.</param>
/// <returns><see langword="true"/> if column width is fix.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="obj"/> is null.</exception>
public static bool IsFixedWidth(this IColumn obj)
=> obj switch
{
null => throw new ArgumentNullException(nameof(obj)),
_ => obj.Width.SizeMode == SizeMode.Fixed,
};

/// <summary>
/// Checks column width size mode.
/// </summary>
/// <param name="obj">The column.</param>
/// <returns><see langword="true"/> if column width is auto-size.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="obj"/> is null.</exception>
public static bool IsAutoWidth(this IColumn obj)
=> obj switch
{
null => throw new ArgumentNullException(nameof(obj)),
_ => obj.Width.SizeMode == SizeMode.SizeToContent,
};
}
4 changes: 2 additions & 2 deletions src/Spectre.Console/IColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ public interface IColumn : IAlignable, IPaddable
/// <summary>
/// Gets or sets the width of the column.
/// </summary>
int? Width { get; set; }
}
ColumnWidth Width { get; set; }
}
16 changes: 16 additions & 0 deletions src/Spectre.Console/SizeMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Spectre.Console;

/// <summary>
/// Defines how the width value will be interpreted.
/// </summary>
public enum SizeMode
{
/// <summary>Fixed size.</summary>
Fixed = 1,

/// <summary>Proportional sizing.</summary>
Proportional = 2,

/// <summary>Auto-size width to content.</summary>
SizeToContent = 0,
}
6 changes: 3 additions & 3 deletions src/Spectre.Console/Widgets/GridColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Spectre.Console;
public sealed class GridColumn : IColumn, IHasDirtyState
{
private bool _isDirty;
private int? _width;
private ColumnWidth _width = ColumnWidth.SizeToContent();
private bool _noWrap;
private Padding? _padding;
private Justify? _alignment;
Expand All @@ -16,9 +16,9 @@ public sealed class GridColumn : IColumn, IHasDirtyState

/// <summary>
/// Gets or sets the width of the column.
/// If <c>null</c>, the column will adapt to its contents.
/// By default it is set to <see cref="ColumnWidth.SizeToContent"/>.
/// </summary>
public int? Width
public ColumnWidth Width
{
get => _width;
set => MarkAsDirty(() => _width = value);
Expand Down
2 changes: 1 addition & 1 deletion src/Spectre.Console/Widgets/Table/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ protected override Measurement Measure(RenderContext context, int maxWidth)
var totalCellWidth = measurer.CalculateTotalCellWidth(maxWidth);

// Calculate the minimum and maximum table width
var measurements = _columns.Select(column => measurer.MeasureColumn(column, totalCellWidth));
var measurements = measurer.MeasureColumns(totalCellWidth);
var minTableWidth = measurements.Sum(x => x.Min) + measurer.GetNonColumnWidth();
var maxTableWidth = Width ?? measurements.Sum(x => x.Max) + measurer.GetNonColumnWidth();
return new Measurement(minTableWidth, maxTableWidth);
Expand Down
6 changes: 3 additions & 3 deletions src/Spectre.Console/Widgets/Table/TableColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ public sealed class TableColumn : IColumn

/// <summary>
/// Gets or sets the width of the column.
/// If <c>null</c>, the column will adapt to its contents.
/// By default it is set to <see cref="ColumnWidth.SizeToContent" />.
/// </summary>
public int? Width { get; set; }
public ColumnWidth Width { get; set; }

/// <summary>
/// Gets or sets the padding of the column.
Expand Down Expand Up @@ -54,7 +54,7 @@ public TableColumn(string header)
public TableColumn(IRenderable header)
{
Header = header ?? throw new ArgumentNullException(nameof(header));
Width = null;
Width = ColumnWidth.SizeToContent();
Padding = new Padding(1, 0, 1, 0);
NoWrap = false;
Alignment = null;
Expand Down
45 changes: 39 additions & 6 deletions src/Spectre.Console/Widgets/Table/TableMeasurer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public int GetNonColumnWidth()
/// <returns>A list of column widths.</returns>
public List<int> CalculateColumnWidths(int maxWidth)
{
var width_ranges = Columns.Select(column => MeasureColumn(column, maxWidth)).ToArray();
var width_ranges = MeasureColumns(maxWidth);
var widths = width_ranges.Select(range => range.Max).ToList();

var tableWidth = widths.Sum();
Expand All @@ -64,11 +64,11 @@ public List<int> CalculateColumnWidths(int maxWidth)
widths = CollapseWidths(widths, wrappable, maxWidth);
tableWidth = widths.Sum();

// last resort, reduce columns evenly
// Last resort, reduce columns evenly
if (tableWidth > maxWidth)
{
var excessWidth = tableWidth - maxWidth;
widths = Ratio.Reduce(excessWidth, widths.Select(_ => 1).ToList(), widths, widths);
widths = Ratio.Reduce(excessWidth, widths.ConvertAll(_ => 1), widths, widths);
tableWidth = widths.Sum();
}
}
Expand All @@ -82,12 +82,45 @@ public List<int> CalculateColumnWidths(int maxWidth)
return widths;
}

public Measurement MeasureColumn(TableColumn column, int maxWidth)
internal Measurement[] MeasureColumns(int maxWidth)
{
IEnumerable<(int Index, Measurement Measurement)> MeasureStarColumns(IEnumerable<(TableColumn Column, int Index)> starColumns, int totalWidthForStar)
{
var sumStarWeight = starColumns.Sum(x => x.Column.Width.Value);
var ratio = totalWidthForStar / sumStarWeight;
foreach (var x in starColumns)
{
var starWidth = (int)Math.Round(x.Column.Width.Value * ratio);

yield return (x.Index, new Measurement(starWidth, starWidth));
}
}

// Index and separate columns
var indexColumns = Columns.Select((column, index) => (column, index)).ToList();
var fixedColumns = indexColumns.Where(x => !x.column.IsProportionalWidth());
var starColumns = indexColumns.Where(x => x.column.IsProportionalWidth());

// First calculate fixed cells
var fixedWidth_ranges = fixedColumns.Select(x => (x.index, measurement: MeasureColumn(x.column, maxWidth))).ToList();

// Get remainder
var consumedWidth = fixedWidth_ranges.Sum(x => x.measurement.Max);
var totalWidthForStar = Math.Max(0, maxWidth - consumedWidth);

// And apportioned to the remainder
var starWidth_ranges = MeasureStarColumns(starColumns, totalWidthForStar);
var width_ranges = fixedWidth_ranges.Concat(starWidth_ranges).OrderBy(x => x.Item1).Select(x => x.Item2);

return width_ranges.ToArray();
}

private Measurement MeasureColumn(TableColumn column, int maxWidth)
{
// Predetermined width?
if (column.Width != null)
if (column.IsFixedWidth())
{
return new Measurement(column.Width.Value, column.Width.Value);
return new Measurement((int)column.Width.Value, (int)column.Width.Value);
}

var columnIndex = Columns.IndexOf(column);
Expand Down