diff --git a/src/ConsoleBuffer/Buffer.cs b/src/ConsoleBuffer/Buffer.cs index e0c3f04..f3d57a3 100644 --- a/src/ConsoleBuffer/Buffer.cs +++ b/src/ConsoleBuffer/Buffer.cs @@ -1,4 +1,4 @@ -namespace ConsoleBuffer +namespace ConsoleBuffer { using System; using System.Collections.Generic; @@ -15,22 +15,22 @@ public sealed class Buffer : INotifyPropertyChanged private short cursorX; private short cursorY; private int currentChar; + /// + /// we store X/Y as 0-offset indexes for convenience. escape codes will pass these around as 1-offset (top left is 1,1) + /// and we'll translate that nonsense where we have to. + /// public (short X, short Y) CursorPosition => (this.cursorX, this.cursorY); public bool CursorVisible { get; private set; } public bool CursorBlink { get; private set; } - private short bufferTopVisibleLine - { - get - { - return (short)Math.Max(0, this.lines.Size - this.Height); - } - } - private short currentLine + private int topVisibleLine; + private int bottomVisibleLine; + + private int CurrentLine { get { - return (short)(this.bufferTopVisibleLine + this.CursorPosition.Y); + return this.topVisibleLine + this.CursorPosition.Y; } } @@ -44,7 +44,14 @@ public Buffer(short width, short height) this.Width = width; this.Height = height; this.CursorVisible = this.CursorBlink = true; - this.lines.PushBack(new Line()); + this.cursorX = this.cursorY = 0; + + for (var y = 0; y < this.Height; ++y) + { + this.lines.PushBack(new Line()); + } + this.topVisibleLine = 0; + this.bottomVisibleLine = this.Height - 1; } public void Append(byte[] bytes, int length) @@ -58,8 +65,20 @@ public void Append(byte[] bytes, int length) switch (this.parser.Append(this.currentChar)) { case ParserAppendResult.Render: - this.lines[this.currentLine].Set(this.cursorX, new Character { Glyph = this.currentChar }); - this.cursorX = (short)Math.Min(this.Width - 1, this.cursorX + 1); + this.lines[this.CurrentLine].Set(this.cursorX, new Character { Glyph = this.currentChar }); + ++this.cursorX; + if (this.cursorX == this.Width) + { + this.cursorX = 0; + if (this.cursorY == this.Height - 1) + { + this.ScrollDown(); + } + else + { + ++this.cursorY; + } + } break; case ParserAppendResult.Complete: this.ExecuteParserCommand(); @@ -102,10 +121,13 @@ private void ExecuteParserCommand() this.OnPropertyChanged("Title"); } break; + case Commands.ControlSequence csiCommand: + this.HandleControlSequence(csiCommand); + break; case Commands.Unsupported unsupported: break; default: - throw new InvalidOperationException("Unknown command type passed."); + throw new InvalidOperationException($"Unknown command type passed: {this.parser.Command?.GetType()}."); } } @@ -127,9 +149,9 @@ private void HandleControlCharacter(Commands.ControlCharacter.ControlCode code) break; case Commands.ControlCharacter.ControlCode.FF: // NB: could clear screen with this if we were so inclined. apparently xterm treats this as LF though, let's emulate. case Commands.ControlCharacter.ControlCode.LF: - if (this.currentLine == this.lines.Size - 1) + if (this.CurrentLine == this.bottomVisibleLine) { - this.lines.PushBack(new Line()); + this.ScrollDown(); } this.cursorY = (short)Math.Min(this.Height - 1, this.cursorY + 1); @@ -145,6 +167,74 @@ private void HandleControlCharacter(Commands.ControlCharacter.ControlCode code) } } + private void HandleControlSequence(Commands.ControlSequence cmd) + { + switch (cmd) + { + case Commands.EraseInDisplay eid: + switch (eid.Direction) + { + case Commands.EraseInDisplay.Parameter.All: + for (var y = this.topVisibleLine; y <= this.bottomVisibleLine; ++y) + { + this.lines[y].Clear(); + } + break; + case Commands.EraseInDisplay.Parameter.Above: + for (var y = this.topVisibleLine; y < this.CurrentLine; ++y) + { + this.lines[y].Clear(); + } + break; + case Commands.EraseInDisplay.Parameter.Below: + for (var y = this.CurrentLine; y < this.bottomVisibleLine; ++y) + { + this.lines[y].Clear(); + } + break; + } + break; + case Commands.SetMode sm: + switch (sm.Setting) + { + case Commands.SetMode.Parameter.CursorBlink: + this.CursorBlink = sm.Set; + break; + case Commands.SetMode.Parameter.CursorShow: + this.CursorVisible = sm.Set; + break; + } + break; + default: + throw new InvalidOperationException($"Unknown CSI command type {cmd.GetType()}."); + } + } + + /// + /// Scroll the visible buffer down, adding new lines if needed. + /// If we're at the bottom of the buffer we will replace lines from the top with new, blank lines. + /// + private void ScrollDown(int lines = 1) + { + while (lines > 0) + { + --lines; + if (this.bottomVisibleLine == this.lines.Capacity - 1) + { + this.lines.PushBack(new Line()); // will force an old line from the buffer; + } + else + { + ++this.topVisibleLine; + ++this.bottomVisibleLine; + if (this.lines.Size <= this.bottomVisibleLine) + { + this.lines.PushBack(new Line()); + } + } + } + } + /// /// Render character-by-character onto the specified target. /// @@ -154,7 +244,7 @@ public void Render(IRenderTarget target) { for (var y = 0; y < this.Height; ++y) { - var renderLine = this.bufferTopVisibleLine + y; + var renderLine = this.topVisibleLine + y; var line = renderLine < this.lines.Size ? this.lines[renderLine] : Line.Empty; short x = 0; foreach (var c in line) diff --git a/src/ConsoleBuffer/Commands/ControlSequence.cs b/src/ConsoleBuffer/Commands/ControlSequence.cs index 6c5368c..fd1ed26 100644 --- a/src/ConsoleBuffer/Commands/ControlSequence.cs +++ b/src/ConsoleBuffer/Commands/ControlSequence.cs @@ -9,20 +9,26 @@ public static Base Create(char command, string bufferData) { switch (command) { + case 'h': + case 'l': + return new SetMode(bufferData, command == 'h'); case 'J': - var cmd = new EraseInDisplay(bufferData); - if (!cmd.IsExtended) // currently no support for selective erase in display - return cmd; - break; + return new EraseInDisplay(bufferData); } return new Unsupported($"^[[{bufferData}{command}"); } - protected bool IsExtended { get; private set; } + public bool IsExtended { get; private set; } protected IList Parameters { get; private set; } protected ControlSequence(string bufferData) : base(bufferData) { } protected override void Parse(string bufferData) { + if (bufferData.Length == 0) + { + this.Parameters = Array.Empty(); + return; + } + var startIndex = 0; if (bufferData[0] == '?') { diff --git a/src/ConsoleBuffer/Commands/EraseInDisplay.cs b/src/ConsoleBuffer/Commands/EraseInDisplay.cs index e3628c9..13147dc 100644 --- a/src/ConsoleBuffer/Commands/EraseInDisplay.cs +++ b/src/ConsoleBuffer/Commands/EraseInDisplay.cs @@ -1,7 +1,36 @@ +using System; + namespace ConsoleBuffer.Commands { public sealed class EraseInDisplay : ControlSequence { - public EraseInDisplay(string bufferData) : base(bufferData) { } + public enum Parameter + { + Below = 0, + Above, + All, + // xterm supports '3' for saved lines. + Unknown, + } + + public Parameter Direction { get; private set; } + + public EraseInDisplay(string bufferData) : base(bufferData) + { + this.Direction = Parameter.Unknown; + if (this.IsExtended) + { + return; // extended means selective erase which we don't support. + } + if (this.Parameters.Count == 0) + { + this.Direction = Parameter.Below; + } + if (this.Parameters.Count == 1 && uint.TryParse(this.Parameters[0], out var param)) + { + param = Math.Min((uint)Parameter.Unknown, param); + this.Direction = (Parameter)param; + } + } } } diff --git a/src/ConsoleBuffer/Commands/SetMode.cs b/src/ConsoleBuffer/Commands/SetMode.cs new file mode 100644 index 0000000..4779874 --- /dev/null +++ b/src/ConsoleBuffer/Commands/SetMode.cs @@ -0,0 +1,42 @@ +namespace ConsoleBuffer.Commands +{ + public sealed class SetMode : ControlSequence + { + /// + /// True if the command was to set (not reset) the parameter. + /// + public bool Set { get; private set; } + public enum Parameter + { + CursorShow = 0, + CursorBlink, + Unknown, + } + + public Parameter Setting { get; private set; } + + public SetMode(string bufferData, bool set) : base(bufferData) + { + this.Set = set; + this.Setting = Parameter.Unknown; + + if (!this.IsExtended) + { + return; + } + + if (this.Parameters.Count == 1) + { + switch (this.Parameters[0]) + { + case "12": + this.Setting = Parameter.CursorBlink; + break; + case "25": + this.Setting = Parameter.CursorShow; + break; + } + } + } + } +} diff --git a/src/ConsoleBuffer/Line.cs b/src/ConsoleBuffer/Line.cs index 874df01..6894f05 100644 --- a/src/ConsoleBuffer/Line.cs +++ b/src/ConsoleBuffer/Line.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -28,6 +28,11 @@ public void Set(int pos, Character ch) this.chars[pos] = ch; } + public void Clear() + { + this.chars.Clear(); + } + private void Extend(int pos) { // XXX: not efficient. @@ -37,14 +42,15 @@ private void Extend(int pos) } } + public IEnumerator GetEnumerator() { - return chars.GetEnumerator(); + return this.chars.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { - return chars.GetEnumerator(); + return this.chars.GetEnumerator(); } public override string ToString() diff --git a/src/condo/Screen.cs b/src/condo/Screen.cs index c57a835..b25b695 100644 --- a/src/condo/Screen.cs +++ b/src/condo/Screen.cs @@ -1,14 +1,12 @@ -namespace condo +namespace condo { using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; - using System.Threading; using System.Windows; using System.Windows.Input; using System.Windows.Media; - using System.Windows.Threading; using ConsoleBuffer; public sealed class Screen : FrameworkElement, IRenderTarget @@ -24,6 +22,7 @@ public sealed class Screen : FrameworkElement, IRenderTarget private readonly Rect cellRectangle; private int horizontalCells, verticalCells; private Character[,] characters; + bool cursorInverted; private volatile int shouldRedraw; private static readonly TimeSpan MaxRedrawFrequency = TimeSpan.FromMilliseconds(10); @@ -63,7 +62,6 @@ public Screen(ConsoleWrapper console) this.Resize(); } - bool cursorBlunk; private void RenderFrame(object sender, EventArgs e) { if (this.redrawWatch.Elapsed >= MaxRedrawFrequency && this.shouldRedraw != 0) @@ -74,12 +72,15 @@ private void RenderFrame(object sender, EventArgs e) this.redrawWatch.Restart(); } - if (this.cursorBlinkWatch.Elapsed >= BlinkFrequency) + if (this.Console.Buffer.CursorVisible) { - (var x, var y) = this.Console.Buffer.CursorPosition; - this.SetCellCharacter(x, y, (char)this.characters[x, y].Glyph, this.cursorBlunk); - this.cursorBlunk = !this.cursorBlunk; - this.cursorBlinkWatch.Restart(); + if (this.cursorBlinkWatch.Elapsed >= BlinkFrequency) + { + this.cursorInverted = this.Console.Buffer.CursorBlink ? !this.cursorInverted : true; + (var x, var y) = this.Console.Buffer.CursorPosition; + this.SetCellCharacter(x, y, (char)this.characters[x, y].Glyph, this.cursorInverted); + this.cursorBlinkWatch.Restart(); + } } } diff --git a/test/ConsoleBufferTests/SequenceParserTests.cs b/test/ConsoleBufferTests/SequenceParserTests.cs index 954086a..5b59df7 100644 --- a/test/ConsoleBufferTests/SequenceParserTests.cs +++ b/test/ConsoleBufferTests/SequenceParserTests.cs @@ -55,16 +55,59 @@ public void OSCommands() const string title = "this is a random title"; var command = $"\x1b]2;{title}\a"; + var parser = this.EnsureCommandParses(command); + var cmd = parser.Command as ConsoleBuffer.Commands.OS; + Assert.IsNotNull(cmd); + Assert.AreEqual(title, cmd.Title); + } + + [TestMethod] + [DataRow("", ConsoleBuffer.Commands.EraseInDisplay.Parameter.Below)] + [DataRow("0", ConsoleBuffer.Commands.EraseInDisplay.Parameter.Below)] + [DataRow("1", ConsoleBuffer.Commands.EraseInDisplay.Parameter.Above)] + [DataRow("2", ConsoleBuffer.Commands.EraseInDisplay.Parameter.All)] + [DataRow("?2", ConsoleBuffer.Commands.EraseInDisplay.Parameter.Unknown)] + [DataRow("-50", ConsoleBuffer.Commands.EraseInDisplay.Parameter.Unknown)] + public void EraseInDisplay(string direction, ConsoleBuffer.Commands.EraseInDisplay.Parameter expectedDirection) + { + var command = $"\x1b[{direction}J"; + var parser = this.EnsureCommandParses(command); + var cmd = parser.Command as ConsoleBuffer.Commands.EraseInDisplay; + Assert.IsNotNull(cmd); + Assert.AreEqual(expectedDirection, cmd.Direction); + } + + [TestMethod] + [DataRow("25", ConsoleBuffer.Commands.SetMode.Parameter.Unknown)] + [DataRow("?25", ConsoleBuffer.Commands.SetMode.Parameter.CursorShow)] + [DataRow("12", ConsoleBuffer.Commands.SetMode.Parameter.Unknown)] + [DataRow("?12", ConsoleBuffer.Commands.SetMode.Parameter.CursorBlink)] + [DataRow("", ConsoleBuffer.Commands.SetMode.Parameter.Unknown)] + [DataRow("?", ConsoleBuffer.Commands.SetMode.Parameter.Unknown)] + public void SetMode(string mode, ConsoleBuffer.Commands.SetMode.Parameter expectedSetting) + { + foreach (var cmdChar in "hl") + { + var on = cmdChar == 'h'; + var command = $"\x1b[{mode}{cmdChar}"; + var parser = this.EnsureCommandParses(command); + var cmd = parser.Command as ConsoleBuffer.Commands.SetMode; + Assert.IsNotNull(cmd); + Assert.AreEqual(on, cmd.Set); + Assert.AreEqual(expectedSetting, cmd.Setting); + } + } + + private SequenceParser EnsureCommandParses(string command) + { var parser = new SequenceParser(); for (var i = 0; i < command.Length - 1; ++i) { Assert.AreEqual(ParserAppendResult.Pending, parser.Append(command[i])); } Assert.AreEqual(ParserAppendResult.Complete, parser.Append(command[command.Length - 1])); - Assert.IsInstanceOfType(parser.Command, typeof(ConsoleBuffer.Commands.OS)); - var osCmd = parser.Command as ConsoleBuffer.Commands.OS; - Assert.AreEqual(title, osCmd.Title); + return parser; } } }