using System.Globalization; using System.Text; namespace XFESystem.Console; /// /// Bounded, incremental VT text terminal model. A caller must serialize writes /// and snapshots, for example through . /// OSC/DCS data is discarded and never interpreted as a file, URL or clipboard command. /// 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 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 value) { foreach (var character in value) Consume(character); } /// Complete a stream: replace an incomplete surrogate and discard an unfinished escape. 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); }