diff --git a/src/XTerm.NET.Tests/StatusLineTests.cs b/src/XTerm.NET.Tests/StatusLineTests.cs
new file mode 100644
index 0000000..e7d83a4
--- /dev/null
+++ b/src/XTerm.NET.Tests/StatusLineTests.cs
@@ -0,0 +1,327 @@
+using XTerm.Options;
+
+namespace XTerm.Tests;
+
+///
+/// The DEC status line: an extra row a program selects with DECSSDT and writes to with DECSASD.
+///
+///
+/// Both controls used to be parsed, stored for DECRQSS, and otherwise ignored — which was
+/// worse than not implementing them. DECRQSS answered with the stored value, so a program asking
+/// whether its status line had been accepted was told yes, wrote to it, and the text went wherever
+/// the cursor happened to be: into the middle of the application's own display. vttest's simple
+/// status-line test shows it as TEXT IN THE STATUS LINEThere should be TEXT IN THE STATUS LINE
+/// on a single row.
+/// The row is deliberately NOT one of the terminal's Rows. An application told it has
+/// N rows must have N rows it can write to, and every size report is computed from that count.
+///
+public class StatusLineTests
+{
+ private static Terminal Fresh() => new(new TerminalOptions { Cols = 40, Rows = 5 });
+
+ private static string Csi(string body) => "\u001b[" + body;
+
+ /// DECSSDT: 0 none, 1 indicator, 2 host-writable.
+ private static string Type(int t) => Csi($"{t}$~");
+
+ /// DECSASD: 0 main display, 1 status line.
+ private static string Select(int d) => Csi($"{d}$}}");
+
+ private static string Row(Terminal t, int row) =>
+ (t.Buffer.GetLine(t.Buffer.ViewportY + row)?.TranslateToString(true) ?? "").TrimEnd();
+
+ private static string StatusText(Terminal t) =>
+ (t.StatusLine?.TranslateToString(true) ?? "").TrimEnd();
+
+ [Fact]
+ public void There_is_no_status_line_until_a_program_asks_for_one()
+ {
+ var terminal = Fresh();
+
+ Assert.Null(terminal.StatusLine);
+ Assert.Equal(0, terminal.StatusDisplayType);
+ Assert.False(terminal.StatusLineActive);
+ }
+
+ [Fact]
+ public void Text_written_to_the_status_line_stays_out_of_the_display()
+ {
+ // vttest's own sequence, and the bug it exposed: the capitals belong on a row of their own.
+ var terminal = Fresh();
+
+ terminal.Write("There should be TEXT IN THE STATUS LINE");
+ terminal.Write(Type(2));
+ terminal.Write(Select(1));
+ terminal.Write("TEXT IN THE STATUS LINE");
+ terminal.Write(Select(0));
+
+ Assert.Equal("There should be TEXT IN THE STATUS LINE", Row(terminal, 0));
+ Assert.Equal("TEXT IN THE STATUS LINE", StatusText(terminal));
+ }
+
+ [Fact]
+ public void The_cursor_comes_back_to_where_the_program_left_it()
+ {
+ // Each display has its own cursor. Losing the application's is how a program that writes a
+ // status message finds itself continuing in the wrong place.
+ var terminal = Fresh();
+
+ terminal.Write("abc");
+ var x = terminal.Buffer.X;
+ var y = terminal.Buffer.Y;
+
+ terminal.Write(Type(2) + Select(1) + "status" + Select(0));
+
+ Assert.Equal(x, terminal.Buffer.X);
+ Assert.Equal(y, terminal.Buffer.Y);
+
+ terminal.Write("def");
+ Assert.Equal("abcdef", Row(terminal, 0));
+ }
+
+ [Fact]
+ public void Selecting_the_status_line_is_refused_when_there_is_not_one()
+ {
+ // The failure this control exists to stop. Honouring the selection with no row to write to
+ // puts the text in the display; refusing keeps the program's own screen intact.
+ var terminal = Fresh();
+
+ terminal.Write("intact");
+ terminal.Write(Select(1));
+
+ Assert.False(terminal.StatusLineActive);
+
+ terminal.Write("!");
+ Assert.Equal("intact!", Row(terminal, 0));
+ }
+
+ [Fact]
+ public void The_indicator_type_is_not_writable_by_the_program()
+ {
+ // Type 1 is the terminal's own indicator; its contents are not the application's to set.
+ var terminal = Fresh();
+
+ terminal.Write("intact");
+ terminal.Write(Type(1));
+ terminal.Write(Select(1));
+
+ Assert.False(terminal.StatusLineActive);
+ Assert.NotNull(terminal.StatusLine);
+
+ terminal.Write("!");
+ Assert.Equal("intact!", Row(terminal, 0));
+ }
+
+ [Fact]
+ public void Removing_the_status_line_hands_the_cursor_back_first()
+ {
+ // Otherwise the row stops existing while it still has the cursor, and everything written
+ // afterwards goes nowhere with no way to recover.
+ var terminal = Fresh();
+
+ terminal.Write("abc" + Type(2) + Select(1) + "status");
+ Assert.True(terminal.StatusLineActive);
+
+ terminal.Write(Type(0));
+
+ Assert.False(terminal.StatusLineActive);
+ Assert.Null(terminal.StatusLine);
+
+ terminal.Write("def");
+ Assert.Equal("abcdef", Row(terminal, 0));
+ }
+
+ [Fact]
+ public void The_status_row_is_not_one_of_the_terminals_rows()
+ {
+ var terminal = Fresh();
+ var rows = terminal.Rows;
+
+ terminal.Write(Type(2));
+
+ Assert.Equal(rows, terminal.Rows);
+ }
+
+ [Fact]
+ public void The_row_follows_the_screens_width()
+ {
+ var terminal = Fresh();
+ terminal.Write(Type(2));
+
+ terminal.Resize(100, 5);
+
+ Assert.Equal(100, terminal.StatusLine!.Length);
+ }
+
+ [Fact]
+ public void A_change_is_announced_once_per_batch()
+ {
+ // A status line is written a character at a time like anything else; a host that repaints
+ // on the event must not repaint once per character of one message.
+ var terminal = Fresh();
+ terminal.Write(Type(2) + Select(1));
+
+ var changes = 0;
+ terminal.StatusLineChanged += (_, _) => changes++;
+
+ terminal.Write("hello");
+
+ Assert.Equal(1, changes);
+ }
+
+ [Fact]
+ public void RIS_takes_the_status_line_with_everything_else()
+ {
+ var terminal = Fresh();
+ terminal.Write(Type(2) + Select(1) + "status");
+
+ // \u001b, not \x1b: a \x escape is VARIABLE length, so "\x1bc" is the single
+ // character U+01BC rather than ESC followed by c. The sequence gets printed instead
+ // of dispatched, and the assertion then fails for a reason that is not the code.
+ terminal.Write("\u001bc");
+
+ Assert.Null(terminal.StatusLine);
+ Assert.Equal(0, terminal.StatusDisplayType);
+ Assert.False(terminal.StatusLineActive);
+ }
+
+ // ------------------------------------------------ from the review on the first version
+
+ [Fact]
+ public void The_status_line_keeps_its_own_cursor_across_selections()
+ {
+ // "Each display has its own cursor" has to hold for the SECOND selection too. Homing on
+ // every DECSASD 1 meant a program writing half a message, stepping back to the screen and
+ // returning began again at column one and overwrote what it had written.
+ var terminal = Fresh();
+ terminal.Write(Type(2));
+
+ terminal.Write(Select(1) + "abc" + Select(0));
+ terminal.Write(Select(1) + "def" + Select(0));
+
+ Assert.Equal("abcdef", StatusText(terminal));
+ }
+
+ [Fact]
+ public void The_displays_pending_wrap_survives_a_trip_to_the_status_line()
+ {
+ // A cursor at the phantom column past the last cell is a different state from one clamped
+ // onto that cell. SetCursor clears the flag, so restoring through it turned a line that was
+ // about to wrap into one that overwrote its own last character.
+ var terminal = Fresh();
+ var width = terminal.Cols;
+
+ terminal.Write(new string('x', width));
+ Assert.True(terminal.Buffer.PendingWrap, "sanity: the cursor is at the phantom column");
+
+ terminal.Write(Type(2) + Select(1) + "status" + Select(0));
+
+ terminal.Write("Z");
+
+ Assert.Equal(new string('x', width), Row(terminal, 0));
+ Assert.Equal("Z", Row(terminal, 1));
+ }
+
+ [Fact]
+ public void Switching_screens_ends_the_status_lines_turn_with_the_cursor()
+ {
+ // The first version reassigned the write target here, so the program's next characters went
+ // to the alternate screen while DECRQSS still reported the status line selected. Holding
+ // the status row instead is not available: the switch's own work -- 1049's erase among it
+ // -- runs through the input handler's buffer, and a full-screen erase against a one-row
+ // buffer indexes off the end of it. So the selection ends, and one rule holds: the status
+ // line is selected, or the screen is.
+ var terminal = Fresh();
+ terminal.Write(Type(2) + Select(1) + "message");
+
+ terminal.Write(Csi("?1049h"));
+
+ Assert.False(terminal.StatusLineActive);
+
+ terminal.Write("on the alternate screen");
+ Assert.Equal("on the alternate screen", Row(terminal, 0));
+
+ // The ROW survives -- only the selection ended.
+ Assert.Equal("message", StatusText(terminal));
+ }
+
+ [Fact]
+ public void The_status_line_can_be_selected_again_from_the_alternate_screen()
+ {
+ var terminal = Fresh();
+ terminal.Write(Type(2) + Select(1) + "message" + Select(0));
+ terminal.Write(Csi("?1049h"));
+
+ terminal.Write(Select(1) + "!" + Select(0));
+
+ Assert.Equal("message!", StatusText(terminal));
+
+ terminal.Write("screen");
+ Assert.Equal("screen", Row(terminal, 0));
+ }
+
+ [Fact]
+ public void DECRQSS_stops_reporting_a_status_line_that_RIS_undid()
+ {
+ // The controls were cached for the report and nothing reset the cache, so after RIS the
+ // terminal said the status line was still selected and still host-writable. A program that
+ // asks is then told yes about a row that no longer exists -- the same lie in a new place.
+ var terminal = Fresh();
+ var replies = new List();
+ terminal.DataReceived += (_, e) => replies.Add(e.Data);
+
+ terminal.Write(Type(2) + Select(1));
+ terminal.Write("\u001bc");
+
+ replies.Clear();
+ terminal.Write("\u001bP$q$}\u001b\\");
+ terminal.Write("\u001bP$q$~\u001b\\");
+
+ Assert.All(replies, r => Assert.DoesNotContain("1$}", r));
+ Assert.All(replies, r => Assert.DoesNotContain("2$~", r));
+ }
+
+ [Fact]
+ public void One_event_for_a_batch_that_selects_and_writes_together()
+ {
+ // The claim the first version made in a comment and did not keep: the two setters each
+ // raised synchronously and the flush raised again, so this emitted three.
+ var terminal = Fresh();
+
+ var changes = 0;
+ terminal.StatusLineChanged += (_, _) => changes++;
+
+ terminal.Write(Type(2) + Select(1) + "hello");
+
+ Assert.Equal(1, changes);
+ }
+
+ [Fact]
+ public void A_host_calling_the_api_directly_is_told_straight_away()
+ {
+ // Outside a batch there is no end to wait for.
+ var terminal = Fresh();
+
+ var changes = 0;
+ terminal.StatusLineChanged += (_, _) => changes++;
+
+ terminal.SetStatusDisplayType(2);
+
+ Assert.Equal(1, changes);
+ }
+
+ [Fact]
+ public void A_zero_sized_terminal_can_still_leave_the_status_line()
+ {
+ // Resize explicitly allows zero -- a host reports it while its control exists but has not
+ // been laid out. SetCursor's clamp is Clamp(x, 0, Cols - 1), which throws when Cols is 0.
+ var terminal = Fresh();
+ terminal.Write(Type(2) + Select(1));
+
+ terminal.Resize(0, 0);
+
+ terminal.Write(Select(0));
+
+ Assert.False(terminal.StatusLineActive);
+ }
+}
diff --git a/src/XTerm.NET.Tests/TerminalTests.cs b/src/XTerm.NET.Tests/TerminalTests.cs
index 21746a0..e39c05a 100644
--- a/src/XTerm.NET.Tests/TerminalTests.cs
+++ b/src/XTerm.NET.Tests/TerminalTests.cs
@@ -470,6 +470,63 @@ public void Dispose_ClearsAllEvents()
Assert.Equal(0, count); // Events should not fire after dispose
}
+ [Fact]
+ public void Dispose_ClearsEveryEvent_NotJustTheOnesSomeoneRemembered()
+ {
+ // Dispose_ClearsAllEvents above is named for the contract and checks two events, which is
+ // how StatusLineChanged was added without cleanup and nothing noticed. Enumerated instead,
+ // so the test cannot fall behind the class: every field-like event on Terminal must be null
+ // after Dispose, and a new one is covered the moment it is declared.
+ //
+ // A retained subscriber is a leak with the terminal on the far end of it -- the host is
+ // usually the subscriber, and it holds a control, which holds a window.
+ var terminal = new Terminal();
+
+ // Subscribe to everything, so a field that was already null cannot pass by accident.
+ var events = typeof(Terminal)
+ .GetEvents(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .ToList();
+
+ Assert.NotEmpty(events);
+
+ var subscribed = new List();
+ foreach (var e in events)
+ {
+ var handlerType = e.EventHandlerType!;
+ var invoke = handlerType.GetMethod("Invoke")!;
+ var parameters = invoke.GetParameters()
+ .Select(p => System.Linq.Expressions.Expression.Parameter(p.ParameterType))
+ .ToArray();
+ var handler = System.Linq.Expressions.Expression.Lambda(
+ handlerType,
+ System.Linq.Expressions.Expression.Empty(),
+ parameters).Compile();
+
+ e.AddEventHandler(terminal, handler);
+ subscribed.Add(e);
+ }
+
+ terminal.Dispose();
+
+ var retained = new List();
+ foreach (var e in subscribed)
+ {
+ // The backing field of a field-like event carries the invocation list. An event with a
+ // hand-written add/remove has none, and cannot be checked this way.
+ var field = typeof(Terminal).GetField(
+ e.Name,
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ if (field is null)
+ continue;
+
+ if (field.GetValue(terminal) is not null)
+ retained.Add(e.Name);
+ }
+
+ Assert.Empty(retained);
+ }
+
[Fact]
public void Write_WithBackspace_MovesBack()
{
diff --git a/src/XTerm.NET/InputHandler.Dcs.cs b/src/XTerm.NET/InputHandler.Dcs.cs
index 8a089ec..27d8052 100644
--- a/src/XTerm.NET/InputHandler.Dcs.cs
+++ b/src/XTerm.NET/InputHandler.Dcs.cs
@@ -155,8 +155,11 @@ private void HandleDecrqss(string setting)
"s" => $"\x1bP1$r{_buffer.ScrollLeft + 1};{_buffer.ScrollRight + 1}s\x1b\\",
"t" => $"\x1bP1$r{_terminal.Rows}t\x1b\\",
"*x" => $"\x1bP1$r{_attributeChangeExtent}*x\x1b\\",
- "$}" => $"\x1bP1$r{_activeStatusDisplay}$}}\x1b\\",
- "$~" => $"\x1bP1$r{_statusDisplayType}$~\x1b\\",
+ // From the TERMINAL, not from the cached copies: those are set when the control is
+ // parsed and nothing resets them, so after RIS this reported the status line still
+ // selected and its type still host-writable when both had been undone.
+ "$}" => $"\x1bP1$r{(_terminal.StatusLineActive ? 1 : 0)}$}}\x1b\\",
+ "$~" => $"\x1bP1$r{_terminal.StatusDisplayType}$~\x1b\\",
"*|" => $"\x1bP1$r{_terminal.Rows}*|\x1b\\",
_ => Deny,
};
diff --git a/src/XTerm.NET/InputHandler.StoredModes.cs b/src/XTerm.NET/InputHandler.StoredModes.cs
index 5ee79cf..4dd0b47 100644
--- a/src/XTerm.NET/InputHandler.StoredModes.cs
+++ b/src/XTerm.NET/InputHandler.StoredModes.cs
@@ -56,8 +56,9 @@ public partial class InputHandler
/// status line to point at and never will.
///
private int _attributeChangeExtent; // DECSACE (* x)
- private int _activeStatusDisplay; // DECSASD ($ })
- private int _statusDisplayType; // DECSSDT ($ ~)
+ // DECSASD ($ }) and DECSSDT ($ ~) were cached here for DECRQSS to report. They are the
+ // terminal's state now, and DECRQSS reads it there -- a second copy is what let RIS undo the
+ // status line while the report went on describing the one that had been undone.
/// Sets or resets a stored mode; false when the mode is not one of the stored set.
private bool TrySetStoredMode(int mode, bool isPrivate, bool value)
diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs
index 12bda2c..103ccc7 100644
--- a/src/XTerm.NET/InputHandler.cs
+++ b/src/XTerm.NET/InputHandler.cs
@@ -757,11 +757,15 @@ public void HandleCsi(string identifier, Params parameters)
break;
case CsiCommand.SelectActiveStatusDisplay:
- _activeStatusDisplay = parameters.GetParam(0, 0);
+ // Stored for DECRQSS as before, and now acted on. Storing the ACCEPTED value
+ // rather than the requested one is the point: DECSASD 1 is refused unless a
+ // host-writable status line exists, and reporting back a selection that was
+ // refused is what told a program its text had somewhere to go when it did not.
+ _terminal.SetActiveStatusDisplay(parameters.GetParam(0, 0));
break;
case CsiCommand.SelectStatusDisplayType:
- _statusDisplayType = parameters.GetParam(0, 0);
+ _terminal.SetStatusDisplayType(parameters.GetParam(0, 0));
break;
case CsiCommand.RequestTerminalParameters:
diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs
index ac05504..37a3758 100644
--- a/src/XTerm.NET/Terminal.cs
+++ b/src/XTerm.NET/Terminal.cs
@@ -63,6 +63,203 @@ public class Terminal : IDisposable
/// DECCOLM's current answer: whether the screen is in 132-column mode.
public bool ColumnMode132 { get; private set; }
+ // ---------------------------------------------------------------- DEC status line
+
+ /// The status line's single row, or when there is not one.
+ ///
+ /// Present only while DECSSDT has selected a type that HAS a row -- host-writable (2), and
+ /// the indicator (1), whose contents the terminal would own. Null for type 0, which is the
+ /// default and means the display has no status line at all.
+ /// A host draws this below the text area. It is deliberately not one of the terminal's
+ /// : an application told it has N rows must have N rows it can write to, and
+ /// every size report is computed from that count.
+ ///
+ public Buffer.BufferLine? StatusLine => _statusDisplayType == 0 ? null : _statusBuffer?.GetLine(0);
+
+ ///
+ /// DECSSDT's current answer: 0 none, 1 indicator, 2 host-writable.
+ ///
+ public int StatusDisplayType => _statusDisplayType;
+
+ ///
+ /// DECSASD's current answer: whether writes are going to the status line rather than the screen.
+ ///
+ public bool StatusLineActive => _statusLineActive;
+
+ /// Raised when the status line's contents, type or cursor change.
+ ///
+ /// Shaped like because a host consumes it the same way: something
+ /// small changed that only the host can show. Raised on the write path, so a host that repaints
+ /// on it repaints once per batch rather than per character.
+ ///
+ public event EventHandler? StatusLineChanged;
+
+ private Buffer.TerminalBuffer? _statusBuffer;
+ private int _statusDisplayType;
+ private bool _statusLineActive;
+
+ // Where the cursor was on the ordinary display while the status line has it. DEC gives each
+ // display its own cursor: DECSASD 1 must not move the application's, and DECSASD 0 must put it
+ // back exactly, or a program that writes a status message loses its place mid-screen.
+ private int _cursorXBeforeStatus, _cursorYBeforeStatus;
+
+ private void EnsureStatusBuffer()
+ {
+ _statusBuffer ??= new Buffer.TerminalBuffer(Cols, 1, 0, hasScrollback: false);
+ _statusBuffer.Resize(Cols, 1);
+ }
+
+ /// Puts the cursor back on the display the status line borrowed it from.
+ ///
+ /// Through the RAW move plus the flag rather than SetCursor, which clamps and clears pending
+ /// wrap. The guard is for the zero-size buffer Resize explicitly allows: a host reports zero
+ /// while its control exists but has not been laid out, and SetCursor's clamp is
+ /// Clamp(x, 0, Cols - 1), which throws when the maximum is negative.
+ ///
+ private void RestoreDisplayCursor()
+ {
+ if (Cols <= 0 || Rows <= 0)
+ return;
+
+ // Clamped to Cols, not Cols - 1. The phantom column past the last cell is a real cursor
+ // position and the one the print path tests to decide whether to wrap; clamping it onto the
+ // last cell turns a line about to wrap into one that overwrites its own last character.
+ // SetCursorRaw derives the flag from that column, which is why it is not set separately.
+ _buffer.SetCursorRaw(Math.Clamp(_cursorXBeforeStatus, 0, Cols),
+ Math.Clamp(_cursorYBeforeStatus, 0, Rows - 1));
+ }
+
+ internal void RaiseStatusLineChanged() => StatusLineChanged?.Invoke(this, EventArgs.Empty);
+
+ private bool _statusLineDirty;
+ private bool _parsing;
+
+ /// Records a status-line change, to be announced at the end of the batch.
+ ///
+ /// Outside a batch -- a host calling the API directly -- there is no end to wait for, so the
+ /// event goes out immediately. Inside one it is held, which is what makes the batching real
+ /// rather than documented: a single write carrying DECSSDT, DECSASD and a message used to emit
+ /// three events, one per setter plus one for the flush.
+ ///
+ private void NoteStatusLineChanged()
+ {
+ if (_parsing)
+ {
+ _statusLineDirty = true;
+ return;
+ }
+
+ RaiseStatusLineChanged();
+ }
+
+ /// Announces a status-line change, at most once for the batch just parsed.
+ ///
+ /// Asked HERE rather than on the print path, which is the hottest code in the parser: a
+ /// per-character test for a state that is almost never on would pay a compare for every
+ /// character of ordinary output to catch a status message that arrives once a second. The
+ /// batch boundary asks the same question once.
+ /// Being selected is enough to count as changed. While DECSASD has the status line, every
+ /// printed character goes into it by definition -- so a batch parsed in that state either wrote
+ /// to the row or was the batch that selected it, and both are things a host wants to repaint
+ /// for. The cost of not tracking it precisely is a repaint of an unchanged row; the cost of
+ /// tracking it precisely is paid by every character the terminal ever prints.
+ ///
+ private void FlushStatusLineChange()
+ {
+ if (!_statusLineDirty && !_statusLineActive)
+ return;
+
+ _statusLineDirty = false;
+ RaiseStatusLineChanged();
+ }
+
+ ///
+ /// DECSSDT ($ ~). Chooses whether there is a status line and who owns it.
+ ///
+ ///
+ /// Type 0 removes it. Leaving the status line SELECTED while removing its row would leave
+ /// writes going nowhere with no way back, so this returns the cursor to the main display first
+ /// -- the same care DECSASD 0 takes.
+ /// Type 1 is the indicator: the terminal owns the contents, not the application. The row
+ /// exists so a host can show something there, and writes are not routed to it.
+ ///
+ public void SetStatusDisplayType(int type)
+ {
+ if (type is < 0 or > 2)
+ return;
+
+ if (type == _statusDisplayType)
+ return;
+
+ if (_statusLineActive)
+ SetActiveStatusDisplay(0);
+
+ _statusDisplayType = type;
+
+ if (type == 0)
+ {
+ _statusBuffer = null;
+ }
+ else
+ {
+ _statusBuffer ??= new Buffer.TerminalBuffer(Cols, 1, 0, hasScrollback: false);
+ _statusBuffer.Resize(Cols, 1);
+ }
+
+ NoteStatusLineChanged();
+ }
+
+ ///
+ /// DECSASD ($ }). Sends what is written next to the status line (1) or the main display (0).
+ ///
+ ///
+ /// Refused unless DECSSDT has selected the HOST-WRITABLE type. Type 0 has no row, and the
+ /// indicator's contents belong to the terminal -- honouring the selection for either would put
+ /// the application's text somewhere it can never be seen, which is the failure this whole
+ /// control exists to stop: text meant for a status line landing in the middle of the screen.
+ /// The buffer swap is the alternate screen's, for the same reason: the input handler
+ /// writes through whichever buffer it was given, so redirecting output is a matter of giving it
+ /// a different one rather than teaching every write about status lines.
+ ///
+ public void SetActiveStatusDisplay(int display)
+ {
+ var wantStatus = display == 1;
+
+ if (wantStatus && _statusDisplayType != 2)
+ return;
+
+ if (wantStatus == _statusLineActive)
+ return;
+
+ if (wantStatus)
+ {
+ // X is saved as it stands, phantom column included: that column is what the print path
+ // reads to decide whether the next character wraps, so rounding it off here would turn
+ // a line about to wrap into one that overwrites its own last character.
+ _cursorXBeforeStatus = _buffer.X;
+ _cursorYBeforeStatus = _buffer.Y;
+
+ EnsureStatusBuffer();
+ _statusLineActive = true;
+ _buffer = _statusBuffer!;
+ _inputHandler.SetBuffer(_buffer);
+
+ // NOT homed. Each display keeps its own cursor, and that has to mean across repeated
+ // selections too: a program that writes half a status message, goes back to the screen
+ // and returns would otherwise start again at column one and overwrite what it wrote.
+ // The buffer is created at (0, 0), which is where a first selection finds it.
+ }
+ else
+ {
+ _statusLineActive = false;
+ _buffer = _usingAltBuffer ? _altBuffer! : _normalBuffer!;
+ _inputHandler.SetBuffer(_buffer);
+ RestoreDisplayCursor();
+ }
+
+ NoteStatusLineChanged();
+ }
+
///
/// DECSET/DECRST 3. Switching clears the screen, homes the cursor and resets the margins --
/// the DEC behaviour programs rely on for a clean slate -- and resizes the grid to 132 or 80
@@ -689,7 +886,11 @@ public void Write(string data)
if (_disposed || string.IsNullOrEmpty(data))
return;
- _parser.Parse(data);
+ _parsing = true;
+ try { _parser.Parse(data); }
+ finally { _parsing = false; }
+
+ FlushStatusLineChange();
}
///
@@ -706,7 +907,11 @@ public void Write(ReadOnlySpan data)
if (_disposed || data.IsEmpty)
return;
- _parser.Parse(data);
+ _parsing = true;
+ try { _parser.Parse(data); }
+ finally { _parsing = false; }
+
+ FlushStatusLineChange();
}
///
@@ -824,6 +1029,11 @@ public void Resize(int cols, int rows)
_normalBuffer?.Resize(cols, rows);
_altBuffer?.Resize(cols, rows);
+ // One row always, only ever as wide as the screen. It is not part of the grid, so the row
+ // count never reaches it -- but a status line narrower than the display would clip its own
+ // text after a widen, and wider would hold text nothing can show.
+ _statusBuffer?.Resize(cols, 1);
+
Resized?.Invoke(this, new TerminalEvents.ResizeEventArgs(cols, rows));
// After the host has been told, not before. The spec requires the report to follow the
@@ -938,6 +1148,12 @@ public void Reset()
var shapeBefore = PointerShape;
+ // The status line goes with everything else RIS undoes: its type, its selection and its
+ // contents. Returning the cursor to the main display FIRST, so the reset below is applied
+ // to the screen rather than to a row that is about to stop existing.
+ SetActiveStatusDisplay(0);
+ SetStatusDisplayType(0);
+
// Reset to normal buffer
if (_usingAltBuffer)
{
@@ -1754,6 +1970,17 @@ public void SwitchToAltBuffer()
var shapeBefore = PointerShape;
// ONE cursor, two screens: xterm shares the cursor across the switch, which is what lets
// DECSET 47 flip screens mid-drawing without teleporting the pen.
+ // Switching screens ends the status line's turn with the cursor. Holding the status row as
+ // the write target across the switch is not available: the switch's own work -- 1049's
+ // erase among it -- runs through the input handler's buffer, so leaving that pointed at a
+ // one-row status buffer sends a full-screen erase off the end of it. Handing the cursor
+ // back first keeps one rule: the status line is selected, or the screen is.
+ //
+ // The ROW is untouched. Only the selection ends, so a program that switches screens and
+ // comes back finds its status message still there.
+ if (_statusLineActive)
+ SetActiveStatusDisplay(0);
+
var x = _buffer.X;
var y = _buffer.Y;
_buffer = _altBuffer!;
@@ -1779,6 +2006,17 @@ public void SwitchToNormalBuffer()
return;
var shapeBefore = PointerShape;
+ // Switching screens ends the status line's turn with the cursor. Holding the status row as
+ // the write target across the switch is not available: the switch's own work -- 1049's
+ // erase among it -- runs through the input handler's buffer, so leaving that pointed at a
+ // one-row status buffer sends a full-screen erase off the end of it. Handing the cursor
+ // back first keeps one rule: the status line is selected, or the screen is.
+ //
+ // The ROW is untouched. Only the selection ends, so a program that switches screens and
+ // comes back finds its status message still there.
+ if (_statusLineActive)
+ SetActiveStatusDisplay(0);
+
var x = _buffer.X;
var y = _buffer.Y;
_buffer = _normalBuffer!;
@@ -1932,6 +2170,7 @@ public void Dispose()
SynchronizedOutputChanged = null;
BufferChanged = null;
TitleChanged = null;
+ StatusLineChanged = null;
BellRang = null;
Resized = null;
Scrolled = null;