File: TabularOutput\TabularOutput.cs
Web Access
Project: ..\..\..\src\Cli\Microsoft.TemplateEngine.Cli\Microsoft.TemplateEngine.Cli.csproj (Microsoft.TemplateEngine.Cli)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Microsoft.TemplateEngine.Cli.TabularOutput
{
    internal class TabularOutput
    {
        internal static TabularOutput<T> For<T>(TabularOutputSettings settings, IEnumerable<T> rows)
        {
            return new TabularOutput<T>(settings, rows);
        }
    }
 
    internal class TabularOutput<T>
    {
        private readonly List<ColumnDefinition> _columns = new();
        private readonly IEnumerable<T> _rowDataItems;
        private readonly List<Tuple<int, bool, IComparer<string>>> _ordering = new();
        private readonly TabularOutputSettings _settings;
 
        internal TabularOutput(TabularOutputSettings settings, IEnumerable<T> rows)
        {
            _rowDataItems = rows ?? Enumerable.Empty<T>();
            _settings = settings;
        }
 
        internal TabularOutput<T> DefineColumn(
            Func<T, string> binder,
            string? header = null,
            string? columnName = null,
            bool shrinkIfNeeded = false,
            int minWidth = 2,
            bool showAlways = false,
            bool defaultColumn = true,
            TextAlign textAlign = TextAlign.Left)
        {
            return DefineColumn(
                binder,
                out object? c,
                header,
                columnName,
                shrinkIfNeeded,
                minWidth,
                showAlways,
                defaultColumn,
                textAlign);
        }
 
        internal TabularOutput<T> DefineColumn(
            Func<T, string> binder,
            out object? column,
            string? header = null,
            string? columnName = null,
            bool shrinkIfNeeded = false,
            int minWidth = 2,
            bool showAlways = false,
            bool defaultColumn = true,
            TextAlign textAlign = TextAlign.Left)
        {
            column = null;
            if ((_settings.ColumnsToDisplay.Count == 0 && defaultColumn) || showAlways || (!string.IsNullOrWhiteSpace(columnName) && _settings.ColumnsToDisplay.Contains(columnName)) || _settings.DisplayAllColumns)
            {
                ColumnDefinition c = new(
                    _settings,
                    header,
                    binder,
                    minWidth: minWidth,
                    shrinkIfNeeded: shrinkIfNeeded,
                    textAlign: textAlign);
                _columns.Add(c);
                column = c;
            }
            return this;
        }
 
        internal string Layout(int indent = 0)
        {
            Dictionary<int, int> columnWidthLookup = new();
            List<TextWrapper[]> grid = new();
 
            TextWrapper[] header = new TextWrapper[_columns.Count];
            int headerLines = 0;
            for (int i = 0; i < _columns.Count; ++i)
            {
                header[i] = new TextWrapper(_columns[i].Header ?? string.Empty, _columns[i].MaxWidth, _settings.NewLine, _settings.ShrinkReplacement);
                headerLines = Math.Max(headerLines, header[i].LineCount);
                columnWidthLookup[i] = header[i].MaxWidth;
            }
 
            foreach (T rowDataItem in _rowDataItems)
            {
                TextWrapper[] row = new TextWrapper[_columns.Count];
                int rowHeight = 0;
 
                for (int i = 0; i < _columns.Count; ++i)
                {
                    row[i] = _columns[i].GetCell(rowDataItem);
                    columnWidthLookup[i] = Math.Max(columnWidthLookup[i], row[i].MaxWidth);
                    rowHeight = Math.Max(rowHeight, row[i].LineCount);
                }
 
                grid.Add(row);
            }
 
            CalculateColumnWidth(columnWidthLookup);
 
            StringBuilder b = new();
 
            // Render column headers, if any exist
            if (_columns.Any(x => !string.IsNullOrEmpty(x.Header)))
            {
                for (int j = 0; j < headerLines; ++j)
                {
                    for (int i = 0; i < _columns.Count; ++i)
                    {
                        header[i].AppendTextWithPadding(b, j, _columns[i].CalculatedWidth, _columns[i].TextAlign);
                        if (i != _columns.Count - 1)
                        {
                            b.Append(' ', _settings.ColumnPadding);
                        }
                    }
                    b.AppendLine().Indent(indent);
                }
            }
 
            // Render header separator, if set
            if (_settings.HeaderSeparator.HasValue)
            {
                for (int i = 0; i < _columns.Count; ++i)
                {
                    b.Append(new string(_settings.HeaderSeparator.Value, _columns[i].CalculatedWidth));
 
                    if (i < _columns.Count - 1)
                    {
                        b.Append(new string(' ', _settings.ColumnPadding));
                    }
                }
                b.AppendLine().Indent(indent);
            }
 
            IEnumerable<TextWrapper[]> rows = grid;
 
            // Apply ordering to list
            if (_ordering.Count > 0)
            {
                IOrderedEnumerable<TextWrapper[]> orderedRows;
                if (_ordering[0].Item2)
                {
                    orderedRows = rows.OrderByDescending(x => x[_ordering[0].Item1].RawText, _ordering[0].Item3);
                }
                else
                {
                    orderedRows = rows.OrderBy(x => x[_ordering[0].Item1].RawText, _ordering[0].Item3);
                }
 
                for (int i = 1; i < _ordering.Count; ++i)
                {
                    int localI = i;
                    if (_ordering[i].Item2)
                    {
                        orderedRows = orderedRows.ThenByDescending(x => x[_ordering[localI].Item1].RawText, _ordering[i].Item3);
                    }
                    else
                    {
                        orderedRows = orderedRows.ThenBy(x => x[_ordering[localI].Item1].RawText, _ordering[i].Item3);
                    }
                }
 
                rows = orderedRows;
            }
 
            // Render row contents (each row can have more than 1 line for multi-line content)
            int currentRowIndex = 0;
            foreach (TextWrapper[] rowToRender in rows)
            {
                for (int lineWithinRow = 0; lineWithinRow < rowToRender.Max(row => row.LineCount); ++lineWithinRow)
                {
                    // Render all columns
                    for (int columnIndex = 0; columnIndex < _columns.Count; ++columnIndex)
                    {
                        rowToRender[columnIndex].AppendTextWithPadding(b, lineWithinRow, _columns[columnIndex].CalculatedWidth, _columns[columnIndex].TextAlign);
                        if (columnIndex != _columns.Count - 1)
                        {
                            b.Append(' ', _settings.ColumnPadding);
                        }
                    }
                    b.AppendLine().Indent(indent);
                }
 
                if (_settings.BlankLineBetweenRows)
                {
                    b.AppendLine();
                }
 
                ++currentRowIndex;
            }
 
            return b.ToString().Indent(indent);
        }
 
        internal TabularOutput<T> OrderBy(object? columnToken, IComparer<string>? comparer = null)
        {
            if (columnToken is not ColumnDefinition columnDefinition)
            {
                throw new ArgumentException($"{nameof(columnToken)} is not of type {nameof(ColumnDefinition)}", nameof(columnToken));
            }
            comparer = comparer ?? StringComparer.Ordinal;
            int index = _columns.IndexOf(columnDefinition);
 
            if (index < 0)
            {
                return this;
            }
 
            _ordering.Add(Tuple.Create(index, false, comparer));
            return this;
        }
 
        internal TabularOutput<T> OrderByDescending(object? columnToken, IComparer<string>? comparer = null)
        {
            if (columnToken is not ColumnDefinition columnDefinition)
            {
                throw new ArgumentException($"{nameof(columnToken)} is not of type {nameof(ColumnDefinition)}", nameof(columnToken));
            }
            comparer = comparer ?? StringComparer.Ordinal;
            int index = _columns.IndexOf(columnDefinition);
 
            if (index < 0)
            {
                return this;
            }
 
            _ordering.Add(Tuple.Create(index, true, comparer));
            return this;
        }
 
        private void CalculateColumnWidth(IReadOnlyDictionary<int, int> columnWidthLookup)
        {
            int maxAllowedGridWidth = _settings.ConsoleBufferWidth;
            int totalPaddingWidth = _settings.ColumnPadding * (_columns.Count - 1);
            int maxRowWidth = columnWidthLookup.Sum(column => column.Value) + totalPaddingWidth;
 
            //set identified needed width as the starting point
            for (int i = 0; i < _columns.Count; ++i)
            {
                _columns[i].CalculatedWidth = columnWidthLookup[i];
            }
 
            // If there is no columns to shrink or it fits, use identified width
            if (!_columns.Any(col => col.ShrinkIfNeeded) || maxRowWidth < maxAllowedGridWidth)
            {
                return;
            }
 
            //calculate the minimum length we could have after shrinking
            int minimumLengthNeeded = 0;
            for (int i = 0; i < _columns.Count; ++i)
            {
                if (_columns[i].ShrinkIfNeeded)
                {
                    minimumLengthNeeded += Math.Min(_columns[i].MinWidth, columnWidthLookup[i]);
                }
                else
                {
                    minimumLengthNeeded += columnWidthLookup[i];
                }
            }
 
            //there is not enough space anyway - set all shrinkable columns to minimum width or use actual width if it is less than mininum
            if (minimumLengthNeeded >= maxAllowedGridWidth)
            {
                for (int i = 0; i < _columns.Count; ++i)
                {
                    if (_columns[i].ShrinkIfNeeded)
                    {
                        _columns[i].CalculatedWidth = Math.Min(_columns[i].MinWidth, columnWidthLookup[i]);
                    }
                }
                return;
            }
 
            // If there's a shrinkable column, by how much should it shrink?
            // We need the grid width to be at most 1 less than the buffer width. We don't want exactly
            // the buffer width because that will cause the caret to wrap on the last character, so we
            // stop 1 short of it.
            int amountForShrinkableColumnToGiveUp = maxRowWidth - maxAllowedGridWidth + 1;
            while (amountForShrinkableColumnToGiveUp > 0)
            {
                //picks up the widest column to shrink first
                ColumnDefinition? columnToShrink = _columns.Aggregate<ColumnDefinition, ColumnDefinition?>(null, (selectedColumn, currentColumn) =>
                {
                    if (currentColumn.ShrinkIfNeeded && currentColumn.CalculatedWidth > currentColumn.MinWidth)
                    {
                        if (selectedColumn == null || currentColumn.CalculatedWidth >= selectedColumn.CalculatedWidth)
                        {
                            selectedColumn = currentColumn;
                        }
                    }
                    return selectedColumn;
                });
                if (columnToShrink == null)
                {
                    //there is no column that can be shrinked
                    //this should not happen as we already checked if there is enough space to fit the columns with shrinking
                    break;
                }
                else
                {
                    columnToShrink.CalculatedWidth--;
                    amountForShrinkableColumnToGiveUp--;
                }
            }
        }
 
        private class ColumnDefinition
        {
            private readonly Func<T, string> _binder;
            private readonly TabularOutputSettings _settings;
 
            internal ColumnDefinition(
                TabularOutputSettings settings,
                string? header,
                Func<T, string> binder,
                int minWidth = 2,
                int maxWidth = -1,
                bool shrinkIfNeeded = false,
                TextAlign textAlign = TextAlign.Left)
            {
                Header = header;
                MaxWidth = maxWidth > 0 ? maxWidth : int.MaxValue;
                _binder = binder;
                ShrinkIfNeeded = shrinkIfNeeded;
                _settings = settings;
                MinWidth = minWidth + _settings.ShrinkReplacement.Length; //we need to add required width for shrink replacement
                TextAlign = textAlign;
            }
 
            internal string? Header { get; }
 
            internal int CalculatedWidth { get; set; }
 
            internal int MinWidth { get; }
 
            internal int MaxWidth { get; }
 
            internal bool ShrinkIfNeeded { get; }
 
            internal TextAlign TextAlign { get; }
 
            internal bool LeftAlign { get; }
 
            internal bool RightAlign { get; }
 
            internal TextWrapper GetCell(T value)
            {
                return new TextWrapper(_binder(value), MaxWidth, _settings.NewLine, _settings.ShrinkReplacement);
            }
        }
 
        private class TextWrapper
        {
            private readonly IReadOnlyList<string> _lines;
            private readonly string _shrinkReplacement;
            private readonly IDictionary<TextAlign, Func<string, int, StringBuilder, StringBuilder>> _alignRules = new Dictionary<TextAlign, Func<string, int, StringBuilder, StringBuilder>>()
            {
                { TextAlign.Left, (string abbreviatedText, int maxColumnWidth, StringBuilder b) => b.Append(abbreviatedText).Append(' ', maxColumnWidth - abbreviatedText.GetUnicodeLength()) },
                {
                    TextAlign.Center, (string abbreviatedText, int maxColumnWidth, StringBuilder b) =>
                    {
                        var centralPoint = (maxColumnWidth + abbreviatedText.GetUnicodeLength()) / 2;
                        var leftOffset = maxColumnWidth - centralPoint;
                        var rightOffset = maxColumnWidth - (leftOffset + abbreviatedText.GetUnicodeLength());
                        return b.Append(' ', leftOffset).Append(abbreviatedText).Append(' ', rightOffset);
                    }
                },
                { TextAlign.Right, (string abbreviatedText, int maxColumnWidth, StringBuilder b) => b.Append(' ', maxColumnWidth - abbreviatedText.GetUnicodeLength()).Append(abbreviatedText) }
            };
 
            internal TextWrapper(string text, int maxWidth, string newLine, string shrinkReplacement)
            {
                List<string> lines = new();
                int position = 0;
                int realMaxWidth = 0;
 
                if (!string.IsNullOrWhiteSpace(text))
                {
                    while (position < text.Length)
                    {
                        int newlineIndex = text.IndexOf(newLine, position, StringComparison.Ordinal);
 
                        if (newlineIndex > -1)
                        {
                            if (text.Substring(position, newlineIndex - position).GetUnicodeLength() <= maxWidth)
                            {
                                lines.Add(text.Substring(position, newlineIndex - position).TrimEnd());
                                position = newlineIndex + newLine.Length;
                            }
                            else
                            {
                                GetLineText(text, lines, maxWidth, newlineIndex, ref position);
                            }
                        }
                        else
                        {
                            GetLineText(text, lines, maxWidth, text.Length - 1, ref position);
                        }
 
                        realMaxWidth = Math.Max(realMaxWidth, lines[lines.Count - 1].GetUnicodeLength());
                    }
                }
 
                _lines = lines;
                MaxWidth = realMaxWidth;
                RawText = text;
                _shrinkReplacement = shrinkReplacement;
            }
 
            internal int LineCount => _lines.Count;
 
            internal int MaxWidth { get; }
 
            internal string RawText { get; }
 
            internal void AppendTextWithPadding(StringBuilder b, int line, int maxColumnWidth, TextAlign textAlign = TextAlign.Left)
            {
                var text = _lines.Count > line ? _lines[line] : string.Empty;
                var abbreviatedText = ShrinkTextToLength(text, maxColumnWidth);
 
                AlignText(textAlign, abbreviatedText, maxColumnWidth, b);
            }
 
            private static void GetLineText(string text, List<string> lines, int maxLength, int end, ref int position)
            {
                if (text.Substring(position, text.Length - position).GetUnicodeLength() < maxLength)
                {
                    lines.Add(text.Substring(position));
                    position = text.Length;
                    return;
                }
 
                int lastBreak = text.LastIndexOfAny(new[] { ' ', '-' }, end, end - position);
                while (lastBreak > 0 && text.Substring(position, lastBreak - position).GetUnicodeLength() > maxLength)
                {
                    --lastBreak;
                    lastBreak = text.LastIndexOfAny(new[] { ' ', '-' }, lastBreak, lastBreak - position);
                }
 
                if (lastBreak > 0)
                {
                    lines.Add(text.Substring(position, lastBreak - position + 1).TrimEnd());
                    position = lastBreak + 1;
                }
                else
                {
                    int properMax = Math.Min(maxLength - 1, text.Length - position);
                    lines.Add(text.Substring(position, properMax) + '-');
                    position += properMax;
                }
            }
 
            private StringBuilder AlignText(TextAlign textAlign, string text, int maxColumnWidth, StringBuilder b) => _alignRules[textAlign](text, maxColumnWidth, b);
 
            private string ShrinkTextToLength(string text, int maxLength)
            {
                if (text.GetUnicodeLength() <= maxLength)
                {
                    // The text is short enough, so return it
                    return text;
                }
                // If the text is too long, shorten it enough to allow room for the ellipsis, then add the ellipsis
 
                int desiredLength = maxLength - _shrinkReplacement.Length;
                int possibleLength = 1;
                while (text.Substring(0, possibleLength).GetUnicodeLength() <= desiredLength)
                {
                    possibleLength++;
                }
                return text.Substring(0, possibleLength - 1) + _shrinkReplacement;
            }
        }
    }
}