Dependencies/Pixie/Pixie.Terminal/Render/HighlightedSourceRenderer.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using WasmBox.Pixie.Code;
using WasmBox.Pixie.Markup;
using WasmBox.Pixie.Terminal.Devices;

namespace WasmBox.Pixie.Terminal.Render {
    /// <summary>
    /// An enumeration of possible highlighted source span types.
    /// </summary>
    public enum HighlightedSourceSpanKind {
        /// <summary>
        /// Non-highlighted source code.
        /// </summary>
        Source,

        /// <summary>
        /// Highlighted source code with focus.
        /// </summary>
        Focus,

        /// <summary>
        /// Highlighted source code without focus.
        /// </summary>
        Highlight
    }

    /// <summary>
    /// Describes a highlighted span of source code.
    /// </summary>
    public struct HighlightedSourceSpan {
        /// <summary>
        /// Creates a highlighted source span from a kind and a
        /// string of text.
        /// </summary>
        /// <param name="kind">The source span's kind.</param>
        /// <param name="text">The source span's text.</param>
        public HighlightedSourceSpan(
            HighlightedSourceSpanKind kind,
            string text) {
            this = default(HighlightedSourceSpan);
            this.Kind = kind;
            this.Text = text;
        }

        /// <summary>
        /// Gets this source span's kind.
        /// </summary>
        /// <returns></returns>
        public HighlightedSourceSpanKind Kind { get; private set; }

        /// <summary>
        /// Gets this span of source code as text.
        /// </summary>
        /// <returns>The source span's text.</returns>
        public string Text { get; private set; }
    }

    /// <summary>
    /// A renderer for highlighted source code.
    /// </summary>
    public class HighlightedSourceRenderer : NodeRenderer {
        /// <summary>
        /// Create a highlighted source renderer.
        /// </summary>
        /// <param name="contextLineCount">
        /// The number of lines that are printed for context
        /// below and above the focus line.
        /// </param>
        public HighlightedSourceRenderer(int contextLineCount)
            : this(contextLineCount, Colors.Green) { }

        /// <summary>
        /// Create a highlighted source renderer that highlights source
        /// using a particular color.
        /// </summary>
        /// <param name="contextLineCount">
        /// The number of lines that are printed for context
        /// below and above the focus line.
        /// </param>
        /// <param name="defaultHighlightColor">The default highlight color.</param>
        public HighlightedSourceRenderer(
            int contextLineCount,
            Color defaultHighlightColor) {
            this.ContextLineCount = contextLineCount;
            this.DefaultHighlightColor = defaultHighlightColor;
            
        }

        /// <summary>
        /// Gets the default color with which source code is highlighted.
        /// </summary>
        /// <returns>The default highlight color.</returns>
        public Color DefaultHighlightColor { get; private set; }

        /// <summary>
        /// Gets the number of lines that are printed for context
        /// below and above the focus line.
        /// </summary>
        /// <returns>The number of context lines.</returns>
        public int ContextLineCount { get; private set; }

        /// <inheritdoc/>
        public override bool CanRender(MarkupNode node) {
            return node is HighlightedSource;
        }

        /// <summary>
        /// The color to highlight source code in.
        /// </summary>
        public const string HighlightColorProperty = "highlight-source-color";

        /// <summary>
        /// Gets the color to highlight source code in.
        /// </summary>
        /// <param name="state">The state of the renderer.</param>
        /// <returns>The source highlighting color.</returns>
        protected Color GetHighlightColor(RenderState state) {
            object colorVal;
            if (state.ThemeProperties.TryGetValue(
                HighlightColorProperty,
                out colorVal)) {
                return (Color)colorVal;
            }
            else {
                return DefaultHighlightColor;
            }
        }

        /// <inheritdoc/>
        public override void Render(MarkupNode node, RenderState state) {
            var src = (HighlightedSource)node;

            var highlightRegion = src.HighlightRegion;
            var focusRegion = src.FocusRegion;
            var document = focusRegion.Document;

            // The idea is to visualize the first line of the focus region,
            // plus a number of lines of context.
            int focusLine = document.GetGridPosition(focusRegion.StartOffset).LineIndex;

            int firstLineNumber = -1;
            var lines = new List<IReadOnlyList<HighlightedSourceSpan>>();
            for (int i = -ContextLineCount; i <= ContextLineCount; i++) {
                var lineSpans = LineToSpans(focusLine + i, highlightRegion, focusRegion);
                if (lineSpans != null) {
                    if (firstLineNumber < 0) {
                        firstLineNumber = focusLine + i;
                    }
                    lines.Add(lineSpans);
                }
            }

            var compressedLines = CompressLeadingWhitespace(lines);
            var maxLineWidth = GetLineWidth(
                firstLineNumber + compressedLines.Count, state);

            state.Terminal.WriteSeparator(2);

            for (int i = 0; i < compressedLines.Count; i++) {
                RenderLine(
                    WrapSpans(compressedLines[i], maxLineWidth),
                    firstLineNumber + i,
                    firstLineNumber + compressedLines.Count - 1,
                    state);
            }

            state.Terminal.WriteSeparator(2);
        }

        /// <summary>
        /// Gets the number of characters a source line can be wide.
        /// </summary>
        /// <param name="greatestLineIndex">The greatest line index to render.</param>
        /// <param name="state">A render state.</param>
        /// <returns>The line width.</returns>
        protected virtual int GetLineWidth(int greatestLineIndex, RenderState state) {
            return state.Terminal.Width
                - GetLineContinuatorPrefix(greatestLineIndex, greatestLineIndex, state).Length // Left padding
                - 4; // Right padding
        }

        /// <summary>
        /// Renders a single line of code that has been line-wrapped.
        /// </summary>
        /// <param name="wrappedSpans">
        /// A line-wrapped list of highlighted spans.
        /// </param>
        /// <param name="lineIndex">The line's zero-based index.</param>
        /// <param name="greatestLineIndex">
        /// The greatest zero-based line index that will be rendered.
        /// </param>
        /// <param name="state">The render state.</param>
        protected virtual void RenderLine(
            IReadOnlyList<IReadOnlyList<HighlightedSourceSpan>> wrappedSpans,
            int lineIndex,
            int greatestLineIndex,
            RenderState state) {
            state.Terminal.Write(
                GetLineNumberPrefix(lineIndex, greatestLineIndex, state));

            // If we encounter an empty line, then we want to write
            // a newline and return.
            var wrappedSpanCount = wrappedSpans.Count;
            if (wrappedSpanCount == 0) {
                state.Terminal.WriteLine();
                return;
            }

            var continuatorPrefix = GetLineContinuatorPrefix(
                lineIndex, greatestLineIndex, state);

            var newTerm = new LayoutTerminal(
                state.Terminal,
                Alignment.Left,
                WrappingStrategy.Character,
                continuatorPrefix,
                state.Terminal.Width);

            newTerm.SuppressPadding();

            var newState = state.WithTerminal(newTerm);

            for (int i = 0; i < wrappedSpanCount; i++) {
                var wrappedSpanLine = wrappedSpans[i];
                for (int j = 0; j < wrappedSpanLine.Count; j++) {
                    RenderSpanText(wrappedSpanLine[j], newState);
                }
                newTerm.WriteLine();

                if (IsHighlighted(wrappedSpanLine)) {
                    for (int j = 0; j < wrappedSpanLine.Count; j++) {
                        RenderSpanSquiggle(wrappedSpanLine[j], newState);
                    }
                    newTerm.WriteLine();
                }
            }

            newTerm.Flush();
        }

        private const string leftWhitespace = "  ";

        /// <summary>
        /// Gets the line number prefix that. is prepended to
        /// each new line.
        /// </summary>
        /// <param name="lineIndex">The index of the line.</param>
        /// <param name="greatestLineIndex">
        /// The greatest line index that will be rendered.
        /// </param>
        /// <param name="state">
        /// A render state.
        /// </param>
        /// <returns>A line number prefix.</returns>
        protected virtual string GetLineNumberPrefix(
            int lineIndex,
            int greatestLineIndex,
            RenderState state) {
            var numString = (lineIndex + 1).ToString();
            var maxString = (greatestLineIndex + 1).ToString();
            var sb = new StringBuilder();
            sb.Append(leftWhitespace);
            sb.Append(' ', maxString.Length - numString.Length);
            sb.Append(numString);
            sb.Append(GetSeparatorBar(state));
            return sb.ToString();
        }

        /// <summary>
        /// Gets the line continuator prefix that. is prepended to
        /// the start of each wrapped line.
        /// </summary>
        /// <param name="lineIndex">The index of the line.</param>
        /// <param name="greatestLineIndex">
        /// The greatest line index that will be rendered.
        /// </param>
        /// <param name="state">
        /// A render state.
        /// </param>
        /// <returns>A line continuator prefix.</returns>
        protected virtual string GetLineContinuatorPrefix(
            int lineIndex, int greatestLineIndex, RenderState state) {
            var maxString = (greatestLineIndex + 1).ToString();
            var sb = new StringBuilder();
            sb.Append(leftWhitespace);
            sb.Append(' ', maxString.Length);
            sb.Append(GetSeparatorBar(state));
            return sb.ToString();
        }

        /// <summary>
        /// Gets a string that contains a bar with whitespace on both sides,
        /// useful for delimiting line numbers and code.
        /// </summary>
        /// <param name="state">A render state.</param>
        /// <returns>A string of characters.</returns>
        protected string GetSeparatorBar(RenderState state) {
            var sb = new StringBuilder();
            sb.Append(' ');
            sb.Append(state.Terminal.GetFirstRenderableString("\u2502", "|", "-") ?? "");
            sb.Append(' ');
            return sb.ToString();
        }

        /// <summary>
        /// Renders a span's text.
        /// </summary>
        /// <param name="span">A span whose text is to be rendered.</param>
        /// <param name="state">A render state.</param>
        protected virtual void RenderSpanText(
            HighlightedSourceSpan span,
            RenderState state) {
            if (span.Kind == HighlightedSourceSpanKind.Focus) {
                state.Terminal.Style.PushForegroundColor(GetHighlightColor(state));
                state.Terminal.Style.PushDecoration(
                    TextDecoration.Bold, DecorationSpan.UnifyDecorations);
                try {
                    state.Terminal.Write(span.Text);
                }
                finally {
                    state.Terminal.Style.PopStyle();
                    state.Terminal.Style.PopStyle();
                }
            }
            else {
                state.Terminal.Write(span.Text);
            }
        }

        /// <summary>
        /// Renders the squiggle under a span.
        /// </summary>
        /// <param name="span">A span for which a squiggle is to be rendered.</param>
        /// <param name="state">A render state.</param>
        protected virtual void RenderSpanSquiggle(
            HighlightedSourceSpan span,
            RenderState state) {
            var spanLength = new StringInfo(span.Text).LengthInTextElements;
            char caret = '^';
            char squiggle = '~';
            if (span.Kind == HighlightedSourceSpanKind.Highlight) {
                state.Terminal.Style.PushForegroundColor(GetHighlightColor(state));
                try {
                    for (int i = 0; i < spanLength; i++) {
                        state.Terminal.Write(squiggle);
                    }
                }
                finally {
                    state.Terminal.Style.PopStyle();
                }
            }
            else if (span.Kind == HighlightedSourceSpanKind.Focus) {
                state.Terminal.Style.PushForegroundColor(GetHighlightColor(state));
                state.Terminal.Style.PushDecoration(
                    TextDecoration.Bold, DecorationSpan.UnifyDecorations);
                try {
                    for (int i = 0; i < spanLength; i++) {
                        state.Terminal.Write(i == 0 ? caret : squiggle);
                    }
                }
                finally {
                    state.Terminal.Style.PopStyle();
                    state.Terminal.Style.PopStyle();
                }
            }
            else {
                for (int i = 0; i < spanLength; i++) {
                    state.Terminal.Write(' ');
                }
            }
        }

        /// <summary>
        /// Identifies and compresses leading whitespace in a list
        /// of lines, where each line is encoded as a highlighted span.
        /// </summary>
        /// <param name="lines">A list of lines.</param>
        /// <returns>A new list of lines.</returns>
        protected virtual IReadOnlyList<IReadOnlyList<HighlightedSourceSpan>> CompressLeadingWhitespace(
            IReadOnlyList<IReadOnlyList<HighlightedSourceSpan>> lines) {
            // TODO: implement this
            return lines;
        }

        /// <summary>
        /// Line-wraps a list of highlighted source spans.
        /// </summary>
        /// <param name="spans">The spans to line-wrap.</param>
        /// <param name="lineLength">The maximum length of a line.</param>
        /// <returns>A list of lines, where each line is encoded as a list of spans.</returns>
        protected virtual IReadOnlyList<IReadOnlyList<HighlightedSourceSpan>> WrapSpans(
            IReadOnlyList<HighlightedSourceSpan> spans,
            int lineLength) {
            var allLines = new List<IReadOnlyList<HighlightedSourceSpan>>();
            var currentLine = new List<HighlightedSourceSpan>();

            var spanCount = spans.Count;
            int length = 0;
            for (int i = 0; i < spanCount; i++) {
                AppendSpanToLine(spans[i], ref currentLine, ref length, allLines, lineLength);
            }

            if (currentLine.Count != 0) {
                allLines.Add(currentLine);
            }

            return allLines;
        }

        private static void AppendSpanToLine(
            HighlightedSourceSpan span,
            ref List<HighlightedSourceSpan> line,
            ref int lineLength,
            List<IReadOnlyList<HighlightedSourceSpan>> allLines,
            int maxLineLength) {
            var spanInfo = new StringInfo(span.Text);
            if (spanInfo.LengthInTextElements == 0) {
                // Do nothing.
            }
            else if (lineLength + spanInfo.LengthInTextElements <= maxLineLength) {
                // Easy case: append entire span to line.
                line.Add(span);
                lineLength += spanInfo.LengthInTextElements;
            }
            else {
                // Slightly more complicated case: split the span.
                var remainingElements = maxLineLength - lineLength;
                var first = spanInfo.SubstringByTextElements(0, remainingElements);
                var second = spanInfo.SubstringByTextElements(remainingElements);

                // Append the first part.
                line.Add(new HighlightedSourceSpan(span.Kind, first));

                // Start a new line.
                allLines.Add(line);
                line = new List<HighlightedSourceSpan>();
                lineLength = 0;

                // Append the second part.
                AppendSpanToLine(
                    new HighlightedSourceSpan(span.Kind, second),
                    ref line,
                    ref lineLength,
                    allLines,
                    maxLineLength);
            }
        }

        private static HighlightedSourceSpanKind GetCharacterKind(
            int offset,
            SourceRegion highlightRegion,
            SourceRegion focusRegion) {
            if (focusRegion.Contains(offset))
                return HighlightedSourceSpanKind.Focus;
            else if (highlightRegion.Contains(offset))
                return HighlightedSourceSpanKind.Highlight;
            else
                return HighlightedSourceSpanKind.Source;
        }

        /// <summary>
        /// Chunks a line into a list of highlighted spans.
        /// </summary>
        /// <param name="lineText">The line's text.</param>
        /// <param name="lineStartOffset">
        /// The offset of the first character in the line.
        /// </param>
        /// <param name="highlightRegion">
        /// The highlighted region.
        /// </param>
        /// <param name="focusRegion">
        /// The focus region.
        /// </param>
        /// <returns>A list of highlighted spans.</returns>
        private static IReadOnlyList<HighlightedSourceSpan> LineToSpans(
            string lineText,
            int lineStartOffset,
            SourceRegion highlightRegion,
            SourceRegion focusRegion) {
            var spans = new List<HighlightedSourceSpan>();

            var kind = HighlightedSourceSpanKind.Source;
            int spanStart = 0;
            for (int i = 0; i < lineText.Length; i++) {
                var charKind = GetCharacterKind(
                    lineStartOffset + i, highlightRegion, focusRegion);
                
                if (charKind != kind) {
                    if (spanStart != i) {
                        spans.Add(
                            new HighlightedSourceSpan(
                                kind,
                                lineText
                                    .Substring(spanStart, i - spanStart)
                                    .Replace("\t", "    ")));
                    }
                    kind = charKind;
                    spanStart = i;
                }
            }

            if (spanStart != lineText.Length) {
                spans.Add(
                    new HighlightedSourceSpan(
                        kind,
                        lineText
                            .Substring(spanStart)
                            .TrimEnd()
                            .Replace("\t", "    ")));
            }
            
            return spans;
        }

        /// <summary>
        /// Chunks a line into a list of highlighted spans.
        /// </summary>
        /// <param name="lineIndex">The line's zero-based index.</param>
        /// <param name="highlightRegion">
        /// The highlighted region.
        /// </param>
        /// <param name="focusRegion">
        /// The focus region.
        /// </param>
        /// <returns>A list of highlighted spans.</returns>
        private static IReadOnlyList<HighlightedSourceSpan> LineToSpans(
            int lineIndex,
            SourceRegion highlightRegion,
            SourceRegion focusRegion) {
            var document = focusRegion.Document;

            int lineStart = document.GetLineOffset(lineIndex);
            int lineEnd = document.GetLineOffset(lineIndex + 1);

            if (lineStart == lineEnd) {
                // Nothing to do here.
                return null;
            }

            var lineText = document.GetText(lineStart, lineEnd - lineStart).TrimEnd();

            return LineToSpans(lineText, lineStart, highlightRegion, focusRegion);
        }

        private static bool IsHighlighted(
            IReadOnlyList<HighlightedSourceSpan> spans) {
            return spans.Any( IsHighlighted);
        }

        private static bool IsHighlighted(
            HighlightedSourceSpan span) {
            return span.Kind != HighlightedSourceSpanKind.Source;
        }
    }
}