Code/Dependencies/Pixie/Pixie.Terminal/Devices/LayoutTerminal.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using WasmBox.Pixie.Markup;
using WasmBox.Pixie.Terminal.Render;
namespace WasmBox.Pixie.Terminal.Devices {
/// <summary>
/// A terminal that buffers lines, aligns them, word-wraps them
/// and writes them to another terminal.
/// </summary>
public sealed class LayoutTerminal : TerminalBase {
/// <summary>
/// Creates a layout terminal from an unaligned terminal,
/// an alignment, a wrapping strategy, a left padding string
/// and a terminal width.
/// </summary>
/// <param name="unalignedTerminal">
/// A terminal to write formatted output to.
/// </param>
/// <param name="alignment">
/// The alignment for each line.
/// </param>
/// <param name="wrapping">
/// The wrapping strategy to use.
/// </param>
/// <param name="leftPadding">
/// A string that is appended to the left of each line.
/// It serves as padding.
/// </param>
/// <param name="width">
/// The width of the terminal: the number of characters that fit on a line.
/// </param>
public LayoutTerminal(
TerminalBase unalignedTerminal,
Alignment alignment,
WrappingStrategy wrapping,
string leftPadding,
int width) {
this.Alignment = alignment;
this.Wrapping = wrapping;
this.UnalignedTerminal = unalignedTerminal;
this.LeftPadding = leftPadding;
this.width = width;
if (width <= 0) {
throw new ArgumentException(nameof(width));
}
this.commandBuffer = new List<Action<TerminalBase>>();
this.style = new BufferingStyleManager(this.commandBuffer);
Reset();
}
private StyleManager style;
/// <inheritdoc/>
public override StyleManager Style => style;
/// <summary>
/// Tells how this terminal aligns lines.
/// </summary>
/// <returns>The alignment this terminal uses.</returns>
public Alignment Alignment { get; private set; }
/// <summary>
/// Gets the padding that is added to the left of each
/// non-empty line.
/// </summary>
/// <value>The left padding.</value>
public string LeftPadding { get; private set; }
/// <summary>
/// Gets the wrapping strategy for this aligned terminal.
/// </summary>
/// <returns>The wrapping strategy.</returns>
public WrappingStrategy Wrapping { get; private set; }
/// <summary>
/// Gets the unaligned terminal to which aligned lines are written.
/// </summary>
/// <returns>A terminal.</returns>
public TerminalBase UnalignedTerminal { get; private set; }
private int width;
/// <inheritdoc/>
public override int Width => width;
private List<Action<TerminalBase>> commandBuffer;
private int lineLength;
/// <summary>
/// Gets the length of the line that is currently in the buffer,
/// measured in text elements.
/// </summary>
/// <returns>The current line length.</returns>
public int BufferedLineLength => lineLength;
private bool suppressNextLinePadding;
/// <summary>
/// Suppresses the padding for a single line.
/// </summary>
public void SuppressPadding() {
suppressNextLinePadding = true;
}
/// <inheritdoc/>
public override bool CanRender(string text) {
return UnalignedTerminal.CanRender(text);
}
/// <inheritdoc/>
public override void Write(string text) {
Write(new StringInfo(text));
}
private void Write(StringInfo text) {
int textLength = text.LengthInTextElements;
if (textLength == 0) {
return;
}
else if (lineLength + textLength <= width) {
lineLength += textLength;
commandBuffer.Add(new TerminalWriteCommand(text.String).Run);
}
else {
var wrapped = WrapLine(text, Wrapping, lineLength, width);
lineLength += LengthInTextElements(wrapped.Item1);
commandBuffer.Add(new TerminalWriteCommand(wrapped.Item1).Run);
WriteLine();
Write(wrapped.Item2);
}
}
/// <inheritdoc/>
public override void WriteLine() {
Flush();
UnalignedTerminal.WriteLine();
}
/// <inheritdoc/>
public override void WriteSeparator(int lineCount) {
if (lineCount > 0) {
Flush();
}
UnalignedTerminal.WriteSeparator(lineCount);
}
/// <summary>
/// Starts a layout box. Flushes any buffered output
/// in the render state to the output terminal, creates
/// a new render state based on this layout terminal and
/// appends a line separator.
/// </summary>
/// <param name="state">
/// The old render state to create a new box in.
/// </param>
/// <returns>A new render state for a layout box.</returns>
public RenderState StartLayoutBox(RenderState state) {
if (state.Terminal is LayoutTerminal) {
((LayoutTerminal)state.Terminal).Flush();
}
WriteSeparator(1);
return state.WithTerminal(this);
}
/// <summary>
/// Ends a layout box by appending a line separator.
/// </summary>
public void EndLayoutBox() {
WriteSeparator(1);
}
/// <summary>
/// Flushes this aligned terminal's buffer to the inner
/// terminal.
/// </summary>
public void Flush() {
if (lineLength > 0 && !suppressNextLinePadding) {
// Write padding.
int padding = GetLeftPaddingSize(
Alignment, lineLength, width);
for (int i = 0; i < padding; i++) {
UnalignedTerminal.Write(' ');
}
UnalignedTerminal.Write(LeftPadding);
}
suppressNextLinePadding = false;
// Flush the command buffer.
for (int i = 0; i < commandBuffer.Count; i++) {
commandBuffer[i](UnalignedTerminal);
}
// Reset the command buffer and line length.
Reset();
}
private void Reset() {
commandBuffer.Clear();
lineLength = 0;
}
private static Tuple<string, string> WrapLine(
StringInfo lineEnd,
WrappingStrategy wrapping,
int printedLineLength,
int width) {
// Make sure that the width >= 1 to prevent line-wrapping
// logic from recursing forever.
width = Math.Max(1, width);
int charsLeftOnThisLine = width - printedLineLength;
if (wrapping == WrappingStrategy.Word) {
// Loop through the line and try to find the word that
// straddles the boundary between the two lines. We want
// to move that word onto the next line.
var str = lineEnd.String;
int wordStartIndex = 0;
for (int i = 0; i < str.Length; i++) {
if (char.IsWhiteSpace(str, i)) {
var substrInfo = new StringInfo(str.Substring(0, i));
int substrLength = substrInfo.LengthInTextElements;
if (substrLength > charsLeftOnThisLine) {
// We found the first word that we'd like to
// put on the next line.
var firstNextLineWord = str.Substring(
wordStartIndex, i - wordStartIndex);
var firstNextLineWordLength = LengthInTextElements(
firstNextLineWord);
if (firstNextLineWordLength > width) {
// The word is too big to put on the
// next line. Revert to per-character
// wrapping.
return SplitAtAndTrim(
lineEnd, charsLeftOnThisLine);
}
else {
// Move the word onto the next line.
return SplitAtAndTrim(
lineEnd, wordStartIndex);
}
}
else {
// We found a word, but it's not the one we're
// looking for. Record the character that trails
// the whitespace as the start of the next word.
wordStartIndex = i + 1;
}
}
}
if (wordStartIndex == 0) {
// Seems like we really can't split this thing.
// Use per-character wrapping as a fallback.
return SplitAt(lineEnd, charsLeftOnThisLine);
}
else {
// Split at the start of a word that isn't delimited
// by trailing whitespace.
return SplitAtAndTrim(lineEnd, wordStartIndex);
}
}
// Per-character wrapping is easy: just print exactly the
// number of characters left on the line.
return SplitAt(lineEnd, charsLeftOnThisLine);
}
private static Tuple<string, string> SplitAt(
StringInfo lineEnd,
int offset) {
return new Tuple<string, string>(
lineEnd.SubstringByTextElements(0, offset),
lineEnd.SubstringByTextElements(offset));
}
private static Tuple<string, string> SplitAtAndTrim(
StringInfo lineEnd,
int offset) {
return new Tuple<string, string>(
lineEnd.SubstringByTextElements(0, offset).TrimEnd(),
lineEnd.SubstringByTextElements(offset).TrimStart());
}
private static int LengthInTextElements(string str) {
return new StringInfo(str).LengthInTextElements;
}
private static int GetLeftPaddingSize(
Alignment alignment,
int lineLength,
int terminalWidth) {
switch (alignment) {
case Alignment.Left:
return 0;
case Alignment.Right:
return Math.Max(terminalWidth - lineLength, 0);
case Alignment.Center:
return GetLeftPaddingSize(
Alignment.Right, lineLength, terminalWidth) / 2;
default:
throw new NotSupportedException(
"Unsupported alignment type: " + alignment);
}
}
/// <summary>
/// Creates an aligned terminal that aligns contents
/// before printing them to another terminal.
/// </summary>
/// <param name="terminal">
/// A terminal to print aligned contents to.
/// </param>
/// <param name="alignment">
/// The alignment of the contents to print.
/// </param>
/// <returns>An aligned terminal.</returns>
public static LayoutTerminal Align(
TerminalBase terminal, Alignment alignment) {
if (terminal is LayoutTerminal) {
var alignedTerm = (LayoutTerminal)terminal;
return new LayoutTerminal(
alignedTerm.UnalignedTerminal,
alignment,
alignedTerm.Wrapping,
alignedTerm.LeftPadding,
alignedTerm.width);
}
else {
return new LayoutTerminal(
terminal,
alignment,
WrappingStrategy.Character,
"",
terminal.Width);
}
}
/// <summary>
/// Creates an aligned terminal that wraps contents
/// before printing them to another terminal.
/// </summary>
/// <param name="terminal">
/// A terminal to print aligned contents to.
/// </param>
/// <param name="wrapping">
/// The wrapping strategy to use.
/// </param>
/// <returns>A wrapping terminal.</returns>
public static LayoutTerminal Wrap(
TerminalBase terminal, WrappingStrategy wrapping) {
if (terminal is LayoutTerminal) {
var alignedTerm = (LayoutTerminal)terminal;
return new LayoutTerminal(
alignedTerm.UnalignedTerminal,
alignedTerm.Alignment,
wrapping,
alignedTerm.LeftPadding,
alignedTerm.width);
}
else {
return new LayoutTerminal(
terminal,
Alignment.Left,
wrapping,
"",
terminal.Width);
}
}
/// <summary>
/// Creates an aligned terminal that inserts a horizontal
/// margin.
/// </summary>
/// <param name="terminal">
/// A terminal to print aligned contents to.
/// </param>
/// <param name="leftMargin">
/// The size of the left margin.
/// </param>
/// <param name="rightMargin">
/// The size of the right margin.
/// </param>
/// <returns>A margin-inserting terminal.</returns>
public static LayoutTerminal AddHorizontalMargin(
TerminalBase terminal, int leftMargin, int rightMargin) {
var leftPadding = new StringBuilder()
.Append(' ', leftMargin)
.ToString();
if (terminal is LayoutTerminal) {
var alignedTerm = (LayoutTerminal)terminal;
return new LayoutTerminal(
alignedTerm.UnalignedTerminal,
alignedTerm.Alignment,
alignedTerm.Wrapping,
alignedTerm.LeftPadding + leftPadding,
Math.Max(1, alignedTerm.width - leftMargin - rightMargin));
}
else {
return new LayoutTerminal(
terminal,
Alignment.Left,
WrappingStrategy.Character,
leftPadding,
Math.Max(1, terminal.Width - leftMargin - rightMargin));
}
}
/// <summary>
/// Creates a layout terminal that inserts additional left padding.
/// </summary>
/// <param name="terminal">
/// A terminal to print aligned contents to.
/// </param>
/// <param name="extraLeftPadding">
/// Additional left padding to print at the start of each line.
/// </param>
/// <returns>A padding-inserting terminal.</returns>
public static LayoutTerminal AddLeftPadding(
TerminalBase terminal, string extraLeftPadding) {
if (terminal is LayoutTerminal) {
var alignedTerm = (LayoutTerminal)terminal;
return new LayoutTerminal(
alignedTerm.UnalignedTerminal,
alignedTerm.Alignment,
alignedTerm.Wrapping,
alignedTerm.LeftPadding + extraLeftPadding,
Math.Max(1, alignedTerm.width - extraLeftPadding.Length));
}
else {
return new LayoutTerminal(
terminal,
Alignment.Left,
WrappingStrategy.Character,
extraLeftPadding,
Math.Max(1, terminal.Width - extraLeftPadding.Length));
}
}
}
internal sealed class BufferingStyleManager : StyleManager {
public BufferingStyleManager(
List<Action<TerminalBase>> commandBuffer) {
this.commandBuffer = commandBuffer;
}
private List<Action<TerminalBase>> commandBuffer;
/// <inheritdoc/>
public override void PushForegroundColor(Color color) {
commandBuffer.Add(new ForegroundColorCommand(color).Run);
}
/// <inheritdoc/>
public override void PushBackgroundColor(Color color) {
commandBuffer.Add(new BackgroundColorCommand(color).Run);
}
/// <inheritdoc/>
public override void PushDecoration(
TextDecoration decoration,
Func<TextDecoration, TextDecoration, TextDecoration> updateDecoration) {
commandBuffer.Add(new DecorationCommand(decoration, updateDecoration).Run);
}
/// <inheritdoc/>
public override void PopStyle() {
commandBuffer.Add(PopStyle);
}
private static void PopStyle(TerminalBase terminal) {
terminal.Style.PopStyle();
}
}
internal sealed class TerminalWriteCommand {
public TerminalWriteCommand(string text) {
this.Text = text;
}
public string Text { get; private set; }
public void Run(TerminalBase terminal) {
terminal.Write(Text);
}
}
internal sealed class ForegroundColorCommand {
public ForegroundColorCommand(Color color) {
this.Color = color;
}
public Color Color { get; private set; }
public void Run(TerminalBase terminal) {
terminal.Style.PushForegroundColor(Color);
}
}
internal sealed class BackgroundColorCommand {
public BackgroundColorCommand(Color color) {
this.Color = color;
}
public Color Color { get; private set; }
public void Run(TerminalBase terminal) {
terminal.Style.PushBackgroundColor(Color);
}
}
internal sealed class DecorationCommand {
public DecorationCommand(
TextDecoration decoration,
Func<TextDecoration, TextDecoration, TextDecoration> updateDecoration) {
this.Decoration = decoration;
this.UpdateDecoration = updateDecoration;
}
public TextDecoration Decoration { get; private set; }
public Func<TextDecoration, TextDecoration, TextDecoration> UpdateDecoration { get; private set; }
public void Run(TerminalBase terminal) {
terminal.Style.PushDecoration(Decoration, UpdateDecoration);
}
}
}