using System.Globalization;
using System.Text;
namespace XFESystem.Console;
/// <summary>
/// Bounded, incremental VT text terminal model. A caller must serialize writes
/// and snapshots, for example through <see cref="TerminalTextWriter"/>.
/// OSC/DCS data is discarded and never interpreted as a file, URL or clipboard command.
/// </summary>
public sealed class TerminalBuffer
{
private enum ParserState { Text, Escape, Csi, CsiDiscard, Osc, OscEscape, String, StringEscape }
private const int MaximumCells = 4_194_304;
private const int MaximumCsiLength = 128;
private readonly StringBuilder csi = new(MaximumCsiLength);
private readonly Queue<TerminalCell[]> scrollback = new();
private readonly int scrollbackLimit;
private TerminalCell[] cells;
private ParserState state;
private char highSurrogate;
private int savedColumn;
private int savedRow;
private int scrollTop;
private int scrollBottom;
private bool wrapPending;
private bool autoWrap = true;
private PrimaryScreen? primaryScreen;
public TerminalBuffer(int columns, int rows, TerminalStyle? defaultStyle = null, int scrollbackLimit = 1_000)
{
ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
if ((long)columns * rows > MaximumCells)
throw new ArgumentOutOfRangeException(nameof(columns), "The terminal exceeds its cell limit.");
ArgumentOutOfRangeException.ThrowIfNegative(scrollbackLimit);
ArgumentOutOfRangeException.ThrowIfGreaterThan(scrollbackLimit, 100_000);
Columns = columns;
Rows = rows;
DefaultStyle = defaultStyle ?? TerminalStyle.Default;
CurrentStyle = DefaultStyle;
this.scrollbackLimit = scrollbackLimit;
scrollBottom = rows - 1;
cells = new TerminalCell[columns * rows];
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
}
public int Columns { get; }
public int Rows { get; }
public int CursorColumn { get; private set; }
public int CursorRow { get; private set; }
public bool CursorVisible { get; private set; } = true;
public TerminalStyle DefaultStyle { get; }
public TerminalStyle CurrentStyle { get; private set; }
public int ScrollbackCount => scrollback.Count;
public bool IsAlternateScreen => primaryScreen is not null;
public TerminalCell GetCell(int column, int row)
{
CheckPosition(column, row);
return cells[row * Columns + column];
}
public TerminalSnapshot Snapshot() => new(Columns, Rows, CursorColumn, CursorRow,
CursorVisible, (TerminalCell[])cells.Clone());
public string GetLine(int row, bool trimEnd = true)
{
CheckPosition(0, row);
var text = new StringBuilder();
for (var column = 0; column < Columns; column++)
{
var cell = cells[row * Columns + column];
if (!cell.IsContinuation)
text.Append(cell.Text);
}
return trimEnd ? text.ToString().TrimEnd(' ') : text.ToString();
}
public string GetText() => string.Join('\n', Enumerable.Range(0, Rows).Select(row => GetLine(row)));
public void Write(string? value)
{
if (value is not null)
Write(value.AsSpan());
}
public void Write(ReadOnlySpan<char> value)
{
foreach (var character in value)
Consume(character);
}
/// <summary>Complete a stream: replace an incomplete surrogate and discard an unfinished escape.</summary>
public void Complete()
{
if (highSurrogate != '\0')
PutRune(Rune.ReplacementChar);
highSurrogate = '\0';
state = ParserState.Text;
csi.Clear();
}
public void Reset()
{
primaryScreen = null;
CurrentStyle = DefaultStyle;
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
scrollback.Clear();
CursorColumn = CursorRow = savedColumn = savedRow = scrollTop = 0;
scrollBottom = Rows - 1;
CursorVisible = autoWrap = true;
wrapPending = false;
highSurrogate = '\0';
csi.Clear();
state = ParserState.Text;
}
private void Consume(char character)
{
switch (state)
{
case ParserState.Osc:
if (character == '\a') state = ParserState.Text;
else if (character == '\u001b') state = ParserState.OscEscape;
return;
case ParserState.OscEscape:
state = character == '\\' || character == '\a' ? ParserState.Text :
character == '\u001b' ? ParserState.OscEscape : ParserState.Osc;
return;
case ParserState.String:
if (character == '\u001b') state = ParserState.StringEscape;
return;
case ParserState.StringEscape:
state = character == '\\' ? ParserState.Text :
character == '\u001b' ? ParserState.StringEscape : ParserState.String;
return;
case ParserState.Csi:
case ParserState.CsiDiscard:
if (character is >= '@' and <= '~')
{
if (state == ParserState.Csi)
ExecuteCsi(character, csi.ToString());
csi.Clear();
state = ParserState.Text;
}
else if (character == '\u001b')
{
csi.Clear();
state = ParserState.Escape;
}
else if (character is '\u0018' or '\u001a')
{
csi.Clear();
state = ParserState.Text;
}
else if (state == ParserState.Csi && character is >= ' ' and <= '?')
{
if (csi.Length < MaximumCsiLength) csi.Append(character);
else state = ParserState.CsiDiscard;
}
return;
case ParserState.Escape:
state = ParserState.Text;
switch (character)
{
case '[': state = ParserState.Csi; csi.Clear(); break;
case ']': state = ParserState.Osc; break;
case 'P': case '^': case '_': state = ParserState.String; break;
case '7': SaveCursor(); break;
case '8': RestoreCursor(); break;
case 'D': LineFeed(); break;
case 'E': CursorColumn = 0; LineFeed(); break;
case 'M': ReverseIndex(); break;
case 'c': Reset(); break;
case '\u001b': state = ParserState.Escape; break;
}
return;
}
if (character == '\u001b')
{
FlushSurrogate();
state = ParserState.Escape;
return;
}
if (character < ' ' || character == '\u007f')
{
FlushSurrogate();
switch (character)
{
case '\r': CursorColumn = 0; wrapPending = false; break;
case '\n': case '\v': case '\f': LineFeed(); break;
case '\b': CursorColumn = Math.Max(0, CursorColumn - 1); wrapPending = false; break;
case '\t': CursorColumn = Math.Min(Columns - 1, (CursorColumn / 8 + 1) * 8); wrapPending = false; break;
}
return;
}
if (highSurrogate != '\0')
{
if (char.IsLowSurrogate(character))
{
var rune = new Rune(highSurrogate, character);
highSurrogate = '\0';
PutRune(rune);
return;
}
FlushSurrogate();
}
if (char.IsHighSurrogate(character)) highSurrogate = character;
else PutRune(char.IsLowSurrogate(character) ? Rune.ReplacementChar : new Rune(character));
}
private void FlushSurrogate()
{
if (highSurrogate == '\0') return;
highSurrogate = '\0';
PutRune(Rune.ReplacementChar);
}
private void PutRune(Rune rune)
{
var category = Rune.GetUnicodeCategory(rune);
if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)
{
var previousColumn = wrapPending ? CursorColumn : CursorColumn - 1;
if (previousColumn >= 0)
{
var index = CursorRow * Columns + previousColumn;
if (cells[index].IsContinuation && previousColumn > 0) index--;
cells[index] = cells[index] with { Text = cells[index].Text + rune };
}
return;
}
var width = IsWide(rune.Value) ? 2 : 1;
if (Columns == 1 && width == 2)
{
width = 1;
rune = Rune.ReplacementChar;
}
if (wrapPending || CursorColumn + width > Columns)
{
if (autoWrap)
{
CursorColumn = 0;
LineFeed();
}
else if (width == 2 && CursorColumn == Columns - 1)
{
return;
}
wrapPending = false;
}
ClearGlyphAt(CursorColumn, CursorRow);
if (width == 2) ClearGlyphAt(CursorColumn + 1, CursorRow);
var offset = CursorRow * Columns + CursorColumn;
cells[offset] = new(rune.ToString(), CurrentStyle, width);
if (width == 2) cells[offset + 1] = new(string.Empty, CurrentStyle, 0);
if (CursorColumn + width >= Columns)
{
CursorColumn = Columns - 1;
wrapPending = autoWrap;
}
else CursorColumn += width;
}
private static bool IsWide(int value) => value is
>= 0x1100 and <= 0x115f or >= 0x2329 and <= 0x232a or
>= 0x2e80 and <= 0xa4cf or >= 0xac00 and <= 0xd7a3 or
>= 0xf900 and <= 0xfaff or >= 0xfe10 and <= 0xfe19 or
>= 0xfe30 and <= 0xfe6f or >= 0xff00 and <= 0xff60 or
>= 0xffe0 and <= 0xffe6 or >= 0x1f300 and <= 0x1faff or
>= 0x20000 and <= 0x3fffd;
private void ExecuteCsi(char final, string text)
{
var privateMode = text.StartsWith('?');
if (privateMode) text = text[1..];
var parts = text.Split(';');
if (parts.Length > 32) return;
var parameters = new int[parts.Length];
for (var index = 0; index < parts.Length; index++)
{
if (parts[index].Length == 0) continue;
if (!int.TryParse(parts[index], NumberStyles.None, CultureInfo.InvariantCulture, out parameters[index]))
return;
}
int Parameter(int index, int fallback = 1) => index >= parameters.Length || parameters[index] == 0
? fallback : parameters[index];
if (privateMode)
{
if (final is not ('h' or 'l')) return;
foreach (var parameter in parameters)
{
switch (parameter)
{
case 7: autoWrap = final == 'h'; wrapPending = false; break;
case 25: CursorVisible = final == 'h'; break;
case 1049: SetAlternateScreen(final == 'h'); break;
}
}
return;
}
var count = Parameter(0);
switch (final)
{
case 'm': SetGraphics(parameters); return;
case 'A': CursorRow = Clamp((long)CursorRow - count, Rows); break;
case 'B': case 'e': CursorRow = Clamp((long)CursorRow + count, Rows); break;
case 'C': case 'a': CursorColumn = Clamp((long)CursorColumn + count, Columns); break;
case 'D': CursorColumn = Clamp((long)CursorColumn - count, Columns); break;
case 'E': CursorRow = Clamp((long)CursorRow + count, Rows); CursorColumn = 0; break;
case 'F': CursorRow = Clamp((long)CursorRow - count, Rows); CursorColumn = 0; break;
case 'G': case '`': CursorColumn = Clamp((long)count - 1, Columns); break;
case 'd': CursorRow = Clamp((long)count - 1, Rows); break;
case 'H': case 'f': CursorRow = Clamp((long)count - 1, Rows); CursorColumn = Clamp((long)Parameter(1) - 1, Columns); break;
case 'J': EraseDisplay(parameters[0]); break;
case 'K': EraseLine(parameters[0]); break;
case 's': SaveCursor(); break;
case 'u': RestoreCursor(); break;
case 'S': ScrollUp(scrollTop, scrollBottom, count); break;
case 'T': ScrollDown(scrollTop, scrollBottom, count); break;
case 'L': if (CursorRow >= scrollTop && CursorRow <= scrollBottom) ScrollDown(CursorRow, scrollBottom, count); break;
case 'M': if (CursorRow >= scrollTop && CursorRow <= scrollBottom) ScrollUp(CursorRow, scrollBottom, count); break;
case '@': InsertCharacters(count); break;
case 'P': DeleteCharacters(count); break;
case 'X': EraseRange(CursorRow * Columns + CursorColumn, Math.Min(count, Columns - CursorColumn)); break;
case 'r':
var top = Parameter(0) - 1;
var bottom = Parameter(1, Rows) - 1;
if (top >= 0 && bottom < Rows && top < bottom)
{
scrollTop = top;
scrollBottom = bottom;
CursorRow = CursorColumn = 0;
}
break;
}
wrapPending = false;
}
private void SetGraphics(int[] parameters)
{
for (var index = 0; index < parameters.Length; index++)
{
var value = parameters[index];
switch (value)
{
case 0: CurrentStyle = DefaultStyle; break;
case 1: CurrentStyle = CurrentStyle with { Bold = true }; break;
case 2: CurrentStyle = CurrentStyle with { Dim = true }; break;
case 3: CurrentStyle = CurrentStyle with { Italic = true }; break;
case 4: CurrentStyle = CurrentStyle with { Underline = true }; break;
case 7: CurrentStyle = CurrentStyle with { Inverse = true }; break;
case 8: CurrentStyle = CurrentStyle with { Hidden = true }; break;
case 9: CurrentStyle = CurrentStyle with { Strike = true }; break;
case 22: CurrentStyle = CurrentStyle with { Bold = false, Dim = false }; break;
case 23: CurrentStyle = CurrentStyle with { Italic = false }; break;
case 24: CurrentStyle = CurrentStyle with { Underline = false }; break;
case 27: CurrentStyle = CurrentStyle with { Inverse = false }; break;
case 28: CurrentStyle = CurrentStyle with { Hidden = false }; break;
case 29: CurrentStyle = CurrentStyle with { Strike = false }; break;
case >= 30 and <= 37: SetColor(TerminalColor.FromIndexed((byte)(value - 30)), true); break;
case >= 40 and <= 47: SetColor(TerminalColor.FromIndexed((byte)(value - 40)), false); break;
case >= 90 and <= 97: SetColor(TerminalColor.FromIndexed((byte)(value - 90 + 8)), true); break;
case >= 100 and <= 107: SetColor(TerminalColor.FromIndexed((byte)(value - 100 + 8)), false); break;
case 39: SetColor(DefaultStyle.Foreground, true); break;
case 49: SetColor(DefaultStyle.Background, false); break;
case 38: case 48:
if (index + 1 >= parameters.Length) return;
var mode = parameters[++index];
if (mode == 5)
{
if (index + 1 >= parameters.Length) return;
var paletteIndex = parameters[++index];
if (paletteIndex <= 255) SetColor(TerminalColor.FromIndexed((byte)paletteIndex), value == 38);
}
else if (mode == 2)
{
if (index + 3 >= parameters.Length) return;
var red = parameters[++index];
var green = parameters[++index];
var blue = parameters[++index];
if (red <= 255 && green <= 255 && blue <= 255)
SetColor(new((byte)red, (byte)green, (byte)blue), value == 38);
}
else return;
break;
}
}
}
private void SetColor(TerminalColor color, bool foreground) => CurrentStyle = foreground
? CurrentStyle with { Foreground = color } : CurrentStyle with { Background = color };
private void LineFeed()
{
wrapPending = false;
if (CursorRow == scrollBottom) ScrollUp(scrollTop, scrollBottom, 1);
else CursorRow = Math.Min(Rows - 1, CursorRow + 1);
}
private void ReverseIndex()
{
wrapPending = false;
if (CursorRow == scrollTop) ScrollDown(scrollTop, scrollBottom, 1);
else CursorRow = Math.Max(0, CursorRow - 1);
}
private void ScrollUp(int top, int bottom, int requested)
{
var count = Math.Min(requested, bottom - top + 1);
if (top == 0 && bottom == Rows - 1 && primaryScreen is null && scrollbackLimit > 0)
{
for (var row = 0; row < count; row++)
{
var line = new TerminalCell[Columns];
Array.Copy(cells, row * Columns, line, 0, Columns);
scrollback.Enqueue(line);
while (scrollback.Count > scrollbackLimit) scrollback.Dequeue();
}
}
Array.Copy(cells, (top + count) * Columns, cells, top * Columns, (bottom - top + 1 - count) * Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), (bottom - count + 1) * Columns, count * Columns);
}
private void ScrollDown(int top, int bottom, int requested)
{
var count = Math.Min(requested, bottom - top + 1);
Array.Copy(cells, top * Columns, cells, (top + count) * Columns, (bottom - top + 1 - count) * Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), top * Columns, count * Columns);
}
private void EraseDisplay(int mode)
{
var position = CursorRow * Columns + CursorColumn;
switch (mode)
{
case 0: EraseRange(position, cells.Length - position); break;
case 1: EraseRange(0, position + 1); break;
case 2: Array.Fill(cells, TerminalCell.Blank(CurrentStyle)); break;
case 3: Array.Fill(cells, TerminalCell.Blank(CurrentStyle)); scrollback.Clear(); break;
}
}
private void EraseLine(int mode)
{
var start = CursorRow * Columns;
switch (mode)
{
case 0: EraseRange(start + CursorColumn, Columns - CursorColumn); break;
case 1: EraseRange(start, CursorColumn + 1); break;
case 2: EraseRange(start, Columns); break;
}
}
private void EraseRange(int start, int count)
{
if (count <= 0) return;
ClearGlyphAt(start % Columns, start / Columns);
var end = start + count - 1;
ClearGlyphAt(end % Columns, end / Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), start, count);
}
private void ClearGlyphAt(int column, int row)
{
var index = row * Columns + column;
if (cells[index].IsContinuation && column > 0)
cells[index - 1] = TerminalCell.Blank(CurrentStyle);
else if (cells[index].DisplayWidth == 2 && column + 1 < Columns)
cells[index + 1] = TerminalCell.Blank(CurrentStyle);
cells[index] = TerminalCell.Blank(CurrentStyle);
}
private void InsertCharacters(int requested)
{
var count = Math.Min(requested, Columns - CursorColumn);
var start = CursorRow * Columns + CursorColumn;
Array.Copy(cells, start, cells, start + count, Columns - CursorColumn - count);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), start, count);
NormalizeWideRow(CursorRow);
}
private void DeleteCharacters(int requested)
{
var count = Math.Min(requested, Columns - CursorColumn);
var start = CursorRow * Columns + CursorColumn;
Array.Copy(cells, start + count, cells, start, Columns - CursorColumn - count);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), (CursorRow + 1) * Columns - count, count);
NormalizeWideRow(CursorRow);
}
private void NormalizeWideRow(int row)
{
for (var column = 0; column < Columns; column++)
{
var index = row * Columns + column;
if (cells[index].IsContinuation && (column == 0 || cells[index - 1].DisplayWidth != 2) ||
cells[index].DisplayWidth == 2 && (column + 1 == Columns || !cells[index + 1].IsContinuation))
cells[index] = TerminalCell.Blank(CurrentStyle);
}
}
private void SaveCursor() { savedColumn = CursorColumn; savedRow = CursorRow; }
private void RestoreCursor() { CursorColumn = savedColumn; CursorRow = savedRow; wrapPending = false; }
private void SetAlternateScreen(bool enabled)
{
if (enabled && primaryScreen is null)
{
primaryScreen = new(cells, CursorColumn, CursorRow, savedColumn, savedRow,
scrollTop, scrollBottom, wrapPending, CurrentStyle);
cells = new TerminalCell[Columns * Rows];
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
CursorColumn = CursorRow = savedColumn = savedRow = scrollTop = 0;
scrollBottom = Rows - 1;
wrapPending = false;
CurrentStyle = DefaultStyle;
}
else if (!enabled && primaryScreen is not null)
{
var saved = primaryScreen;
primaryScreen = null;
cells = saved.Cells;
CursorColumn = saved.Column;
CursorRow = saved.Row;
savedColumn = saved.SavedColumn;
savedRow = saved.SavedRow;
scrollTop = saved.ScrollTop;
scrollBottom = saved.ScrollBottom;
wrapPending = saved.WrapPending;
CurrentStyle = saved.Style;
}
}
private void CheckPosition(int column, int row)
{
ArgumentOutOfRangeException.ThrowIfNegative(column);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(column, Columns);
ArgumentOutOfRangeException.ThrowIfNegative(row);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(row, Rows);
}
private static int Clamp(long value, int dimension) => (int)Math.Clamp(value, 0, dimension - 1);
private sealed record PrimaryScreen(TerminalCell[] Cells, int Column, int Row, int SavedColumn,
int SavedRow, int ScrollTop, int ScrollBottom, bool WrapPending, TerminalStyle Style);
}
using System.Globalization;
using System.Text;
namespace XFESystem.Console;
/// <summary>
/// Bounded, incremental VT text terminal model. A caller must serialize writes
/// and snapshots, for example through <see cref="TerminalTextWriter"/>.
/// OSC/DCS data is discarded and never interpreted as a file, URL or clipboard command.
/// </summary>
public sealed class TerminalBuffer
{
private enum ParserState { Text, Escape, Csi, CsiDiscard, Osc, OscEscape, String, StringEscape }
private const int MaximumCells = 4_194_304;
private const int MaximumCsiLength = 128;
private readonly StringBuilder csi = new(MaximumCsiLength);
private readonly Queue<TerminalCell[]> scrollback = new();
private readonly int scrollbackLimit;
private TerminalCell[] cells;
private ParserState state;
private char highSurrogate;
private int savedColumn;
private int savedRow;
private int scrollTop;
private int scrollBottom;
private bool wrapPending;
private bool autoWrap = true;
private PrimaryScreen? primaryScreen;
public TerminalBuffer(int columns, int rows, TerminalStyle? defaultStyle = null, int scrollbackLimit = 1_000)
{
ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
if ((long)columns * rows > MaximumCells)
throw new ArgumentOutOfRangeException(nameof(columns), "The terminal exceeds its cell limit.");
ArgumentOutOfRangeException.ThrowIfNegative(scrollbackLimit);
ArgumentOutOfRangeException.ThrowIfGreaterThan(scrollbackLimit, 100_000);
Columns = columns;
Rows = rows;
DefaultStyle = defaultStyle ?? TerminalStyle.Default;
CurrentStyle = DefaultStyle;
this.scrollbackLimit = scrollbackLimit;
scrollBottom = rows - 1;
cells = new TerminalCell[columns * rows];
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
}
public int Columns { get; }
public int Rows { get; }
public int CursorColumn { get; private set; }
public int CursorRow { get; private set; }
public bool CursorVisible { get; private set; } = true;
public TerminalStyle DefaultStyle { get; }
public TerminalStyle CurrentStyle { get; private set; }
public int ScrollbackCount => scrollback.Count;
public bool IsAlternateScreen => primaryScreen is not null;
public TerminalCell GetCell(int column, int row)
{
CheckPosition(column, row);
return cells[row * Columns + column];
}
public TerminalSnapshot Snapshot() => new(Columns, Rows, CursorColumn, CursorRow,
CursorVisible, (TerminalCell[])cells.Clone());
public string GetLine(int row, bool trimEnd = true)
{
CheckPosition(0, row);
var text = new StringBuilder();
for (var column = 0; column < Columns; column++)
{
var cell = cells[row * Columns + column];
if (!cell.IsContinuation)
text.Append(cell.Text);
}
return trimEnd ? text.ToString().TrimEnd(' ') : text.ToString();
}
public string GetText() => string.Join('\n', Enumerable.Range(0, Rows).Select(row => GetLine(row)));
public void Write(string? value)
{
if (value is not null)
Write(value.AsSpan());
}
public void Write(ReadOnlySpan<char> value)
{
foreach (var character in value)
Consume(character);
}
/// <summary>Complete a stream: replace an incomplete surrogate and discard an unfinished escape.</summary>
public void Complete()
{
if (highSurrogate != '\0')
PutRune(Rune.ReplacementChar);
highSurrogate = '\0';
state = ParserState.Text;
csi.Clear();
}
public void Reset()
{
primaryScreen = null;
CurrentStyle = DefaultStyle;
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
scrollback.Clear();
CursorColumn = CursorRow = savedColumn = savedRow = scrollTop = 0;
scrollBottom = Rows - 1;
CursorVisible = autoWrap = true;
wrapPending = false;
highSurrogate = '\0';
csi.Clear();
state = ParserState.Text;
}
private void Consume(char character)
{
switch (state)
{
case ParserState.Osc:
if (character == '\a') state = ParserState.Text;
else if (character == '\u001b') state = ParserState.OscEscape;
return;
case ParserState.OscEscape:
state = character == '\\' || character == '\a' ? ParserState.Text :
character == '\u001b' ? ParserState.OscEscape : ParserState.Osc;
return;
case ParserState.String:
if (character == '\u001b') state = ParserState.StringEscape;
return;
case ParserState.StringEscape:
state = character == '\\' ? ParserState.Text :
character == '\u001b' ? ParserState.StringEscape : ParserState.String;
return;
case ParserState.Csi:
case ParserState.CsiDiscard:
if (character is >= '@' and <= '~')
{
if (state == ParserState.Csi)
ExecuteCsi(character, csi.ToString());
csi.Clear();
state = ParserState.Text;
}
else if (character == '\u001b')
{
csi.Clear();
state = ParserState.Escape;
}
else if (character is '\u0018' or '\u001a')
{
csi.Clear();
state = ParserState.Text;
}
else if (state == ParserState.Csi && character is >= ' ' and <= '?')
{
if (csi.Length < MaximumCsiLength) csi.Append(character);
else state = ParserState.CsiDiscard;
}
return;
case ParserState.Escape:
state = ParserState.Text;
switch (character)
{
case '[': state = ParserState.Csi; csi.Clear(); break;
case ']': state = ParserState.Osc; break;
case 'P': case '^': case '_': state = ParserState.String; break;
case '7': SaveCursor(); break;
case '8': RestoreCursor(); break;
case 'D': LineFeed(); break;
case 'E': CursorColumn = 0; LineFeed(); break;
case 'M': ReverseIndex(); break;
case 'c': Reset(); break;
case '\u001b': state = ParserState.Escape; break;
}
return;
}
if (character == '\u001b')
{
FlushSurrogate();
state = ParserState.Escape;
return;
}
if (character < ' ' || character == '\u007f')
{
FlushSurrogate();
switch (character)
{
case '\r': CursorColumn = 0; wrapPending = false; break;
case '\n': case '\v': case '\f': LineFeed(); break;
case '\b': CursorColumn = Math.Max(0, CursorColumn - 1); wrapPending = false; break;
case '\t': CursorColumn = Math.Min(Columns - 1, (CursorColumn / 8 + 1) * 8); wrapPending = false; break;
}
return;
}
if (highSurrogate != '\0')
{
if (char.IsLowSurrogate(character))
{
var rune = new Rune(highSurrogate, character);
highSurrogate = '\0';
PutRune(rune);
return;
}
FlushSurrogate();
}
if (char.IsHighSurrogate(character)) highSurrogate = character;
else PutRune(char.IsLowSurrogate(character) ? Rune.ReplacementChar : new Rune(character));
}
private void FlushSurrogate()
{
if (highSurrogate == '\0') return;
highSurrogate = '\0';
PutRune(Rune.ReplacementChar);
}
private void PutRune(Rune rune)
{
var category = Rune.GetUnicodeCategory(rune);
if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)
{
var previousColumn = wrapPending ? CursorColumn : CursorColumn - 1;
if (previousColumn >= 0)
{
var index = CursorRow * Columns + previousColumn;
if (cells[index].IsContinuation && previousColumn > 0) index--;
cells[index] = cells[index] with { Text = cells[index].Text + rune };
}
return;
}
var width = IsWide(rune.Value) ? 2 : 1;
if (Columns == 1 && width == 2)
{
width = 1;
rune = Rune.ReplacementChar;
}
if (wrapPending || CursorColumn + width > Columns)
{
if (autoWrap)
{
CursorColumn = 0;
LineFeed();
}
else if (width == 2 && CursorColumn == Columns - 1)
{
return;
}
wrapPending = false;
}
ClearGlyphAt(CursorColumn, CursorRow);
if (width == 2) ClearGlyphAt(CursorColumn + 1, CursorRow);
var offset = CursorRow * Columns + CursorColumn;
cells[offset] = new(rune.ToString(), CurrentStyle, width);
if (width == 2) cells[offset + 1] = new(string.Empty, CurrentStyle, 0);
if (CursorColumn + width >= Columns)
{
CursorColumn = Columns - 1;
wrapPending = autoWrap;
}
else CursorColumn += width;
}
private static bool IsWide(int value) => value is
>= 0x1100 and <= 0x115f or >= 0x2329 and <= 0x232a or
>= 0x2e80 and <= 0xa4cf or >= 0xac00 and <= 0xd7a3 or
>= 0xf900 and <= 0xfaff or >= 0xfe10 and <= 0xfe19 or
>= 0xfe30 and <= 0xfe6f or >= 0xff00 and <= 0xff60 or
>= 0xffe0 and <= 0xffe6 or >= 0x1f300 and <= 0x1faff or
>= 0x20000 and <= 0x3fffd;
private void ExecuteCsi(char final, string text)
{
var privateMode = text.StartsWith('?');
if (privateMode) text = text[1..];
var parts = text.Split(';');
if (parts.Length > 32) return;
var parameters = new int[parts.Length];
for (var index = 0; index < parts.Length; index++)
{
if (parts[index].Length == 0) continue;
if (!int.TryParse(parts[index], NumberStyles.None, CultureInfo.InvariantCulture, out parameters[index]))
return;
}
int Parameter(int index, int fallback = 1) => index >= parameters.Length || parameters[index] == 0
? fallback : parameters[index];
if (privateMode)
{
if (final is not ('h' or 'l')) return;
foreach (var parameter in parameters)
{
switch (parameter)
{
case 7: autoWrap = final == 'h'; wrapPending = false; break;
case 25: CursorVisible = final == 'h'; break;
case 1049: SetAlternateScreen(final == 'h'); break;
}
}
return;
}
var count = Parameter(0);
switch (final)
{
case 'm': SetGraphics(parameters); return;
case 'A': CursorRow = Clamp((long)CursorRow - count, Rows); break;
case 'B': case 'e': CursorRow = Clamp((long)CursorRow + count, Rows); break;
case 'C': case 'a': CursorColumn = Clamp((long)CursorColumn + count, Columns); break;
case 'D': CursorColumn = Clamp((long)CursorColumn - count, Columns); break;
case 'E': CursorRow = Clamp((long)CursorRow + count, Rows); CursorColumn = 0; break;
case 'F': CursorRow = Clamp((long)CursorRow - count, Rows); CursorColumn = 0; break;
case 'G': case '`': CursorColumn = Clamp((long)count - 1, Columns); break;
case 'd': CursorRow = Clamp((long)count - 1, Rows); break;
case 'H': case 'f': CursorRow = Clamp((long)count - 1, Rows); CursorColumn = Clamp((long)Parameter(1) - 1, Columns); break;
case 'J': EraseDisplay(parameters[0]); break;
case 'K': EraseLine(parameters[0]); break;
case 's': SaveCursor(); break;
case 'u': RestoreCursor(); break;
case 'S': ScrollUp(scrollTop, scrollBottom, count); break;
case 'T': ScrollDown(scrollTop, scrollBottom, count); break;
case 'L': if (CursorRow >= scrollTop && CursorRow <= scrollBottom) ScrollDown(CursorRow, scrollBottom, count); break;
case 'M': if (CursorRow >= scrollTop && CursorRow <= scrollBottom) ScrollUp(CursorRow, scrollBottom, count); break;
case '@': InsertCharacters(count); break;
case 'P': DeleteCharacters(count); break;
case 'X': EraseRange(CursorRow * Columns + CursorColumn, Math.Min(count, Columns - CursorColumn)); break;
case 'r':
var top = Parameter(0) - 1;
var bottom = Parameter(1, Rows) - 1;
if (top >= 0 && bottom < Rows && top < bottom)
{
scrollTop = top;
scrollBottom = bottom;
CursorRow = CursorColumn = 0;
}
break;
}
wrapPending = false;
}
private void SetGraphics(int[] parameters)
{
for (var index = 0; index < parameters.Length; index++)
{
var value = parameters[index];
switch (value)
{
case 0: CurrentStyle = DefaultStyle; break;
case 1: CurrentStyle = CurrentStyle with { Bold = true }; break;
case 2: CurrentStyle = CurrentStyle with { Dim = true }; break;
case 3: CurrentStyle = CurrentStyle with { Italic = true }; break;
case 4: CurrentStyle = CurrentStyle with { Underline = true }; break;
case 7: CurrentStyle = CurrentStyle with { Inverse = true }; break;
case 8: CurrentStyle = CurrentStyle with { Hidden = true }; break;
case 9: CurrentStyle = CurrentStyle with { Strike = true }; break;
case 22: CurrentStyle = CurrentStyle with { Bold = false, Dim = false }; break;
case 23: CurrentStyle = CurrentStyle with { Italic = false }; break;
case 24: CurrentStyle = CurrentStyle with { Underline = false }; break;
case 27: CurrentStyle = CurrentStyle with { Inverse = false }; break;
case 28: CurrentStyle = CurrentStyle with { Hidden = false }; break;
case 29: CurrentStyle = CurrentStyle with { Strike = false }; break;
case >= 30 and <= 37: SetColor(TerminalColor.FromIndexed((byte)(value - 30)), true); break;
case >= 40 and <= 47: SetColor(TerminalColor.FromIndexed((byte)(value - 40)), false); break;
case >= 90 and <= 97: SetColor(TerminalColor.FromIndexed((byte)(value - 90 + 8)), true); break;
case >= 100 and <= 107: SetColor(TerminalColor.FromIndexed((byte)(value - 100 + 8)), false); break;
case 39: SetColor(DefaultStyle.Foreground, true); break;
case 49: SetColor(DefaultStyle.Background, false); break;
case 38: case 48:
if (index + 1 >= parameters.Length) return;
var mode = parameters[++index];
if (mode == 5)
{
if (index + 1 >= parameters.Length) return;
var paletteIndex = parameters[++index];
if (paletteIndex <= 255) SetColor(TerminalColor.FromIndexed((byte)paletteIndex), value == 38);
}
else if (mode == 2)
{
if (index + 3 >= parameters.Length) return;
var red = parameters[++index];
var green = parameters[++index];
var blue = parameters[++index];
if (red <= 255 && green <= 255 && blue <= 255)
SetColor(new((byte)red, (byte)green, (byte)blue), value == 38);
}
else return;
break;
}
}
}
private void SetColor(TerminalColor color, bool foreground) => CurrentStyle = foreground
? CurrentStyle with { Foreground = color } : CurrentStyle with { Background = color };
private void LineFeed()
{
wrapPending = false;
if (CursorRow == scrollBottom) ScrollUp(scrollTop, scrollBottom, 1);
else CursorRow = Math.Min(Rows - 1, CursorRow + 1);
}
private void ReverseIndex()
{
wrapPending = false;
if (CursorRow == scrollTop) ScrollDown(scrollTop, scrollBottom, 1);
else CursorRow = Math.Max(0, CursorRow - 1);
}
private void ScrollUp(int top, int bottom, int requested)
{
var count = Math.Min(requested, bottom - top + 1);
if (top == 0 && bottom == Rows - 1 && primaryScreen is null && scrollbackLimit > 0)
{
for (var row = 0; row < count; row++)
{
var line = new TerminalCell[Columns];
Array.Copy(cells, row * Columns, line, 0, Columns);
scrollback.Enqueue(line);
while (scrollback.Count > scrollbackLimit) scrollback.Dequeue();
}
}
Array.Copy(cells, (top + count) * Columns, cells, top * Columns, (bottom - top + 1 - count) * Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), (bottom - count + 1) * Columns, count * Columns);
}
private void ScrollDown(int top, int bottom, int requested)
{
var count = Math.Min(requested, bottom - top + 1);
Array.Copy(cells, top * Columns, cells, (top + count) * Columns, (bottom - top + 1 - count) * Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), top * Columns, count * Columns);
}
private void EraseDisplay(int mode)
{
var position = CursorRow * Columns + CursorColumn;
switch (mode)
{
case 0: EraseRange(position, cells.Length - position); break;
case 1: EraseRange(0, position + 1); break;
case 2: Array.Fill(cells, TerminalCell.Blank(CurrentStyle)); break;
case 3: Array.Fill(cells, TerminalCell.Blank(CurrentStyle)); scrollback.Clear(); break;
}
}
private void EraseLine(int mode)
{
var start = CursorRow * Columns;
switch (mode)
{
case 0: EraseRange(start + CursorColumn, Columns - CursorColumn); break;
case 1: EraseRange(start, CursorColumn + 1); break;
case 2: EraseRange(start, Columns); break;
}
}
private void EraseRange(int start, int count)
{
if (count <= 0) return;
ClearGlyphAt(start % Columns, start / Columns);
var end = start + count - 1;
ClearGlyphAt(end % Columns, end / Columns);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), start, count);
}
private void ClearGlyphAt(int column, int row)
{
var index = row * Columns + column;
if (cells[index].IsContinuation && column > 0)
cells[index - 1] = TerminalCell.Blank(CurrentStyle);
else if (cells[index].DisplayWidth == 2 && column + 1 < Columns)
cells[index + 1] = TerminalCell.Blank(CurrentStyle);
cells[index] = TerminalCell.Blank(CurrentStyle);
}
private void InsertCharacters(int requested)
{
var count = Math.Min(requested, Columns - CursorColumn);
var start = CursorRow * Columns + CursorColumn;
Array.Copy(cells, start, cells, start + count, Columns - CursorColumn - count);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), start, count);
NormalizeWideRow(CursorRow);
}
private void DeleteCharacters(int requested)
{
var count = Math.Min(requested, Columns - CursorColumn);
var start = CursorRow * Columns + CursorColumn;
Array.Copy(cells, start + count, cells, start, Columns - CursorColumn - count);
Array.Fill(cells, TerminalCell.Blank(CurrentStyle), (CursorRow + 1) * Columns - count, count);
NormalizeWideRow(CursorRow);
}
private void NormalizeWideRow(int row)
{
for (var column = 0; column < Columns; column++)
{
var index = row * Columns + column;
if (cells[index].IsContinuation && (column == 0 || cells[index - 1].DisplayWidth != 2) ||
cells[index].DisplayWidth == 2 && (column + 1 == Columns || !cells[index + 1].IsContinuation))
cells[index] = TerminalCell.Blank(CurrentStyle);
}
}
private void SaveCursor() { savedColumn = CursorColumn; savedRow = CursorRow; }
private void RestoreCursor() { CursorColumn = savedColumn; CursorRow = savedRow; wrapPending = false; }
private void SetAlternateScreen(bool enabled)
{
if (enabled && primaryScreen is null)
{
primaryScreen = new(cells, CursorColumn, CursorRow, savedColumn, savedRow,
scrollTop, scrollBottom, wrapPending, CurrentStyle);
cells = new TerminalCell[Columns * Rows];
Array.Fill(cells, TerminalCell.Blank(DefaultStyle));
CursorColumn = CursorRow = savedColumn = savedRow = scrollTop = 0;
scrollBottom = Rows - 1;
wrapPending = false;
CurrentStyle = DefaultStyle;
}
else if (!enabled && primaryScreen is not null)
{
var saved = primaryScreen;
primaryScreen = null;
cells = saved.Cells;
CursorColumn = saved.Column;
CursorRow = saved.Row;
savedColumn = saved.SavedColumn;
savedRow = saved.SavedRow;
scrollTop = saved.ScrollTop;
scrollBottom = saved.ScrollBottom;
wrapPending = saved.WrapPending;
CurrentStyle = saved.Style;
}
}
private void CheckPosition(int column, int row)
{
ArgumentOutOfRangeException.ThrowIfNegative(column);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(column, Columns);
ArgumentOutOfRangeException.ThrowIfNegative(row);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(row, Rows);
}
private static int Clamp(long value, int dimension) => (int)Math.Clamp(value, 0, dimension - 1);
private sealed record PrimaryScreen(TerminalCell[] Cells, int Column, int Row, int SavedColumn,
int SavedRow, int ScrollTop, int ScrollBottom, bool WrapPending, TerminalStyle Style);
}