diff --git a/src/XTerm.NET.Tests/DeviceReportTests.cs b/src/XTerm.NET.Tests/DeviceReportTests.cs
index b7d14f5..9805492 100644
--- a/src/XTerm.NET.Tests/DeviceReportTests.cs
+++ b/src/XTerm.NET.Tests/DeviceReportTests.cs
@@ -139,4 +139,31 @@ public void Decrqtsr_WithNoParameterAsksForNothing()
terminal.Write(Esc + "[$u");
Assert.Empty(replies);
}
+
+ [Fact]
+ public void Decrqcra_IsSilentBelowLevel64()
+ {
+ var (terminal, replies) = Create();
+ terminal.Write(Esc + "[64\"p");
+ terminal.Write(Esc + "[1;0;1;1;1;1*y");
+ Assert.Single(replies); // answered at VT400
+
+ replies.Clear();
+ terminal.Write(Esc + "[62\"p"); // DECSCL: VT200
+ terminal.Write(Esc + "[1;0;1;1;1;1*y");
+ Assert.Empty(replies);
+ }
+
+ [Fact]
+ public void Decrqtsr_IsSilentBelowLevel64()
+ {
+ // The control is VT320 vintage, but the capability the primary DA offers for it --
+ // attribute 17, terminal state interrogation -- is advertised only from level 64.
+ // Declining a request the DA reply has already said the terminal does not take is the
+ // terminal contradicting itself.
+ var (terminal, replies) = Create();
+ terminal.Write(Esc + "[62\"p");
+ terminal.Write(Esc + "[1$u");
+ Assert.Empty(replies);
+ }
}
diff --git a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
index 752a1b7..9d6ccea 100644
--- a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
+++ b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
@@ -386,4 +386,65 @@ public void The_alternate_buffer_keeps_its_own_images()
terminal.Write($"{Esc}[?1049l"); // and back
Assert.Equal(8, ImageCellCount(terminal));
}
+
+ ///
+ /// The counterpart to every test above: a control that changes RENDITION and not text must
+ /// leave the picture alone.
+ ///
+ ///
+ /// DECCARA sets attributes over an area and never touches a character, so a picture inside that
+ /// area is not being overwritten and must not be split. Going through the ordinary text-write
+ /// path made it look like one -- that path splits the placement at the column being written,
+ /// which is right for printing over a picture and wrong for recolouring the cell under it.
+ ///
+ [Fact]
+ public void An_attribute_change_over_a_picture_leaves_it_whole()
+ {
+ var terminal = Fresh();
+ WriteSixel(terminal);
+ var image = ImageAssertions.ImageAt(terminal, 0, 0);
+ Assert.NotNull(image);
+ Assert.Equal(8, ImageCellCount(terminal));
+
+ // Bold over the whole picture and then some.
+ terminal.Write($"{Esc}[2*x{Esc}[1;1;4;4;1$r");
+
+ Assert.Equal(8, ImageCellCount(terminal));
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 0, 0), image));
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 1, 3), image));
+ Assert.True(terminal.Buffer.Lines[terminal.Buffer.YBase]![0].Attributes.IsBold());
+ }
+
+ ///
+ /// And a request that changes nothing must not even write the cells back, because writing a
+ /// cell back unchanged is still a write as far as the placements are concerned.
+ ///
+ [Fact]
+ public void An_attribute_change_naming_nothing_we_implement_touches_no_cell()
+ {
+ var terminal = Fresh();
+ WriteSixel(terminal);
+ var image = ImageAssertions.ImageAt(terminal, 0, 0);
+ Assert.Equal(8, ImageCellCount(terminal));
+
+ // 31 is a colour, which DECCARA does not carry; the request names nothing this implements.
+ terminal.Write($"{Esc}[2*x{Esc}[1;1;4;4;31$r");
+
+ Assert.Equal(8, ImageCellCount(terminal));
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 0, 0), image));
+ }
+
+ /// A DECRARA whose toggles cancel each other is the same nothing.
+ [Fact]
+ public void Toggles_that_cancel_leave_the_picture_and_the_rendition_alone()
+ {
+ var terminal = Fresh();
+ WriteSixel(terminal);
+ Assert.Equal(8, ImageCellCount(terminal));
+
+ terminal.Write($"{Esc}[2*x{Esc}[1;1;4;4;1;1$t");
+
+ Assert.Equal(8, ImageCellCount(terminal));
+ Assert.False(terminal.Buffer.Lines[terminal.Buffer.YBase]![0].Attributes.IsBold());
+ }
}
diff --git a/src/XTerm.NET.Tests/RectangleOpsTests.cs b/src/XTerm.NET.Tests/RectangleOpsTests.cs
index e4e1618..b6f2d3e 100644
--- a/src/XTerm.NET.Tests/RectangleOpsTests.cs
+++ b/src/XTerm.NET.Tests/RectangleOpsTests.cs
@@ -301,4 +301,60 @@ public void Decsace_survives_a_soft_reset_and_not_a_hard_one()
terminal.Write($"{Esc}[2;3;3;6;1$r");
Assert.True(AttrAt(terminal, 1, 9).IsBold()); // back to a stream
}
+
+ ///
+ /// The whole family is VT400, which is the gate xterm puts on each of them and what this
+ /// terminal's own primary DA already says by advertising attribute 28 only from level 64.
+ /// A program that lowered the level with DECSCL asked to be treated as older hardware.
+ ///
+ [Theory]
+ [InlineData("[42;1;1;2;4$x")] // DECFRA
+ [InlineData("[1;1;2;4$z")] // DECERA
+ [InlineData("[1;1;2;4${")] // DECSERA
+ [InlineData("[1;1;1;4;2;1$v")] // DECCRA
+ [InlineData("[1;1;2;4;1$r")] // DECCARA
+ [InlineData("[1;1;2;4;1$t")] // DECRARA
+ public void The_rectangle_family_is_refused_below_level_64(string sequence)
+ {
+ var terminal = NewTerminal();
+ terminal.Write("abcdefghij");
+ var before = Row(terminal, 0, 10);
+ var boldBefore = AttrAt(terminal, 0, 0).IsBold();
+
+ terminal.Write($"{Esc}[62\"p"); // DECSCL: VT200
+ terminal.Write($"{Esc}{sequence}");
+
+ Assert.Equal(before, Row(terminal, 0, 10));
+ Assert.Equal(boldBefore, AttrAt(terminal, 0, 0).IsBold());
+ }
+
+ [Fact]
+ public void The_rectangle_family_works_again_at_level_64()
+ {
+ var terminal = NewTerminal();
+ terminal.Write($"{Esc}[62\"p{Esc}[64\"p"); // down to VT200 and back up to VT400
+ terminal.Write($"{Esc}[42;1;1;1;3$x");
+
+ Assert.Equal("*** ", Row(terminal, 0, 10));
+ }
+
+ ///
+ /// DECSACE is the family's one exception, and it is xterm's asymmetry rather than an oversight:
+ /// its handler has no level test where every neighbour has one. Storing which extent a program
+ /// would prefer changes nothing by itself -- the two controls that read it are gated -- so
+ /// there is nothing to refuse.
+ ///
+ [Fact]
+ public void Decsace_is_stored_at_every_level()
+ {
+ var terminal = NewTerminal();
+ var replies = new List();
+ terminal.DataReceived += (_, e) => replies.Add(e.Data);
+
+ terminal.Write($"{Esc}[62\"p"); // VT200
+ terminal.Write($"{Esc}[2*x"); // DECSACE 2
+ terminal.Write($"{Esc}P$q*x{Esc}\\"); // DECRQSS
+
+ Assert.Equal($"{Esc}P1$r2*x{Esc}\\", Assert.Single(replies));
+ }
}
diff --git a/src/XTerm.NET/InputHandler.Csi.cs b/src/XTerm.NET/InputHandler.Csi.cs
index 3689f8e..13994fe 100644
--- a/src/XTerm.NET/InputHandler.Csi.cs
+++ b/src/XTerm.NET/InputHandler.Csi.cs
@@ -828,6 +828,15 @@ private void RequestUserPreferredSupplementalSet()
///
private void RequestTerminalStateReport(Params parameters)
{
+ // The refusal is still a report, and this terminal's primary DA offers attribute 17,
+ // terminal state interrogation, only from level 64. Answering below that would have the
+ // terminal declining a request it had already said it does not take -- so a program that
+ // lowered the level with DECSCL gets the same silence every other unavailable control
+ // gives it. The control itself is VT320 vintage; the capability it belongs to is what the
+ // DA reply gates, and matching the reply is what keeps the two from disagreeing.
+ if (_terminal.ConformanceLevel < 64)
+ return;
+
if (parameters.GetParam(0, 0) == 0)
return;
@@ -1744,6 +1753,12 @@ private void RestoreCursor()
///
private void RequestChecksumRectangularArea(Params parameters)
{
+ // VT400 and up, with the rest of the rectangle family. esctest asserts the level before it
+ // reads a single cell back through this -- AssertVTLevel(4, "checksum") -- so the gate is
+ // one the conformance suite already assumes is here.
+ if (!RectangularEditingAvailable)
+ return;
+
var id = parameters.GetParam(0, 0);
// parameters[1] is the page, ignored. Coordinates are read in the ORIGIN MODE system,
// like a cursor address and like every rectangle operation: a program that addresses its
diff --git a/src/XTerm.NET/InputHandler.Rectangles.cs b/src/XTerm.NET/InputHandler.Rectangles.cs
index 22e4a48..c7aede8 100644
--- a/src/XTerm.NET/InputHandler.Rectangles.cs
+++ b/src/XTerm.NET/InputHandler.Rectangles.cs
@@ -44,6 +44,24 @@ private bool TryReadRectangle(Params parameters, int first,
return top >= 0 && left >= 0 && top <= bottom && left <= right;
}
+ ///
+ /// Whether the DEC rectangular-editing controls are available at the current operating level.
+ ///
+ ///
+ /// VT400 and up, which is the gate xterm puts on every one of them --
+ /// screen->vtXX_level >= 4 on DECCRA, DECERA, DECFRA, DECSERA, DECCARA, DECRARA
+ /// and DECRQCRA alike. It is also what this terminal's own primary DA already says: attribute
+ /// 28, rectangular editing, is advertised only from level 64. Acting on the controls at a level
+ /// where the DA reply denies them is the terminal contradicting itself, and a program that
+ /// lowered the level with DECSCL specifically to be treated as older hardware has asked not to
+ /// be given them.
+ /// DECSACE is deliberately NOT gated, which is xterm's asymmetry rather than an oversight
+ /// on this side: its handler has no level test where every neighbour does. Storing which extent
+ /// a program would prefer costs nothing and changes nothing on its own -- the two controls that
+ /// read it are gated here, so a stored preference below level 64 simply never gets used.
+ ///
+ private bool RectangularEditingAvailable => _terminal.ConformanceLevel >= 64;
+
/// DECFRA -- fills the rectangle with one character, in the CURRENT rendition.
///
/// The character must be printable -- xterm accepts 32..126 and 160 up -- and an
@@ -52,6 +70,9 @@ private bool TryReadRectangle(Params parameters, int first,
///
private void FillRectangularArea(Params parameters)
{
+ if (!RectangularEditingAvailable)
+ return;
+
var ch = parameters.GetParam(0, 0);
if (ch < 32 || (ch > 126 && ch < 160))
return;
@@ -65,6 +86,8 @@ private void FillRectangularArea(Params parameters)
/// DECERA -- erases the rectangle to blanks, with the erase attributes.
private void EraseRectangularArea(Params parameters)
{
+ if (!RectangularEditingAvailable)
+ return;
if (!TryReadRectangle(parameters, 0, out var top, out var left, out var bottom, out var right))
return;
@@ -79,6 +102,8 @@ private void EraseRectangularArea(Params parameters)
///
private void SelectiveEraseRectangularArea(Params parameters)
{
+ if (!RectangularEditingAvailable)
+ return;
if (!TryReadRectangle(parameters, 0, out var top, out var left, out var bottom, out var right))
return;
@@ -124,6 +149,8 @@ private void FillCells(int top, int left, int bottom, int right, ref BufferCell
///
private void CopyRectangularArea(Params parameters)
{
+ if (!RectangularEditingAvailable)
+ return;
if (!TryReadRectangle(parameters, 0, out var top, out var left, out var bottom, out var right))
return;
@@ -171,15 +198,21 @@ private void CopyRectangularArea(Params parameters)
}
}
+ ///
+ /// What the parameter list asks of one attribute. Toggling twice is the same as not asking,
+ /// which is why this composes rather than accumulating a list.
+ ///
+ private enum AreaAttributeOp : byte { None, Set, Clear, Toggle }
+
+ /// The five attributes DECCARA and DECRARA can name, in the order the ops are held.
+ private const int AreaBold = 0, AreaUnderline = 1, AreaBlink = 2, AreaInverse = 3, AreaInvisible = 4;
+
///
/// DECCARA (CSI Pt;Pl;Pb;Pr;Pm $ r) and DECRARA (CSI Pt;Pl;Pb;Pr;Pm $ t) -- set
/// or toggle the named SGR attributes over an area, leaving the characters alone.
///
///
- /// The attribute half of the rectangle family, and the only consumer DECSACE has. That
- /// setting was parsed, stored and read back by DECRQSS while nothing acted on it, because the
- /// two controls it governs did not exist: a terminal reporting a rectangle-or-stream choice it
- /// then ignored.
+ /// The attribute half of the rectangle family, and the only consumer DECSACE has.
/// DECSACE 2 means the RECTANGLE the four coordinates describe. Anything else -- the
/// default included -- means the STREAM running from the top-left position to the bottom-right
/// one, so the first row runs from its column to the end of the line, the last row from the
@@ -190,6 +223,18 @@ private void CopyRectangularArea(Params parameters)
/// and reverses rather than clears them under DECRARA. Everything else in the list is ignored;
/// colours are not in the standard, and honouring an SGR parameter here that a real VT420 would
/// not is how a program's careful rectangle ends up recoloured on one terminal only.
+ /// The list is read ONCE, into one op per attribute, rather than re-walked for every
+ /// cell: the answer cannot vary across the area, and a full-screen request asked the same
+ /// question a parameter at a time for every one of its cells. Reading it first is also what
+ /// makes the next paragraph possible.
+ /// A request that changes nothing -- CSI 1;1;1;10;31 $ r, naming only a colour
+ /// this does not implement, or a DECRARA whose toggles cancel -- returns before a cell is
+ /// touched. That is NOT an optimisation. Writing a cell back unchanged still counts as writing
+ /// it, and the write path splits any Sixel or Kitty placement covering that column, on the
+ /// reasonable assumption that a cell being written is a cell whose character is changing. Here
+ /// it never is: these two controls change rendition and nothing else, so the cells that DO
+ /// change go back through the INDEXER, which stores the cell and invalidates the render cache
+ /// without disturbing the picture over it.
/// Every cell in the area is marked, the trailing half of a wide character included. xterm
/// skips cells it has never drawn -- it tracks that per cell, and a blank it has never touched
/// is not a blank it will colour -- but a line here is born full of spaces, so there is no such
@@ -198,6 +243,14 @@ private void CopyRectangularArea(Params parameters)
///
private void MarkRectangularArea(Params parameters, bool reverse)
{
+ // VT400 and up, with the rest of the family; see RectangularEditingAvailable.
+ if (!RectangularEditingAvailable)
+ return;
+
+ Span ops = stackalloc AreaAttributeOp[5];
+ if (!ReadAreaAttributeOps(parameters, 4, ops, reverse))
+ return;
+
if (!TryReadRectangle(parameters, 0, out var top, out var left, out var bottom, out var right))
return;
@@ -215,71 +268,114 @@ private void MarkRectangularArea(Params parameters, bool reverse)
for (var col = from; col <= to && col < line.Length; col++)
{
var cell = line[col];
- ApplyAreaAttributes(parameters, 4, ref cell.Attributes, reverse);
- line.SetCell(col, ref cell);
+ ApplyAreaAttributeOps(ops, ref cell.Attributes);
+
+ // The indexer, NOT SetCell: see the remarks. SetCell is the text-write path and
+ // splits this line's placements, so a rendition change over a picture would punch
+ // a hole in it.
+ line[col] = cell;
}
}
}
///
- /// Applies the DECCARA/DECRARA attribute list starting at to one
- /// cell's rendition.
+ /// Reads the DECCARA/DECRARA parameter list into one operation per attribute.
///
- private static void ApplyAreaAttributes(Params parameters, int first, ref AttributeData attributes, bool reverse)
+ ///
+ /// Composing rather than appending is what keeps one pass faithful to the list's order: a later
+ /// parameter overrides an earlier one for the same attribute, and a toggle applied to a pending
+ /// toggle cancels it -- exactly as xterm's per-cell XOR does when the same bit is named twice.
+ ///
+ /// False when the list would change nothing, so the caller can touch no cells at all.
+ private static bool ReadAreaAttributeOps(Params parameters, int first, Span ops, bool reverse)
{
+ var on = reverse ? AreaAttributeOp.Toggle : AreaAttributeOp.Set;
+ var off = reverse ? AreaAttributeOp.Toggle : AreaAttributeOp.Clear;
+
for (var i = first; i < parameters.Length; i++)
{
switch (parameters.GetParam(i, 0))
{
case 0:
- if (reverse)
- {
- attributes.SetBold(!attributes.IsBold());
- attributes.SetUnderline(!attributes.IsUnderline());
- attributes.SetBlink(!attributes.IsBlink());
- attributes.SetInverse(!attributes.IsInverse());
- }
- else
- {
- attributes.SetBold(false);
- attributes.SetUnderline(false);
- attributes.SetBlink(false);
- attributes.SetInverse(false);
- }
- break;
- case 1:
- attributes.SetBold(reverse ? !attributes.IsBold() : true);
- break;
- case 4:
- attributes.SetUnderline(reverse ? !attributes.IsUnderline() : true);
- break;
- case 5:
- attributes.SetBlink(reverse ? !attributes.IsBlink() : true);
- break;
- case 7:
- attributes.SetInverse(reverse ? !attributes.IsInverse() : true);
- break;
- case 8:
- attributes.SetInvisible(reverse ? !attributes.IsInvisible() : true);
+ // xterm's SGR_MASK: bold, underline, blink and inverse -- not invisible, which
+ // has its own 8 and 28.
+ Note(ops, AreaBold, off);
+ Note(ops, AreaUnderline, off);
+ Note(ops, AreaBlink, off);
+ Note(ops, AreaInverse, off);
break;
+ case 1: Note(ops, AreaBold, on); break;
+ case 4: Note(ops, AreaUnderline, on); break;
+ case 5: Note(ops, AreaBlink, on); break;
+ case 7: Note(ops, AreaInverse, on); break;
+ case 8: Note(ops, AreaInvisible, on); break;
// The resets have no meaning under DECRARA -- reversing an attribute already says
// both directions -- so xterm reads them only when setting, and so does this.
- case 22 when !reverse:
- attributes.SetBold(false);
- break;
- case 24 when !reverse:
- attributes.SetUnderline(false);
- break;
- case 25 when !reverse:
- attributes.SetBlink(false);
- break;
- case 27 when !reverse:
- attributes.SetInverse(false);
- break;
- case 28 when !reverse:
- attributes.SetInvisible(false);
- break;
+ case 22 when !reverse: Note(ops, AreaBold, AreaAttributeOp.Clear); break;
+ case 24 when !reverse: Note(ops, AreaUnderline, AreaAttributeOp.Clear); break;
+ case 25 when !reverse: Note(ops, AreaBlink, AreaAttributeOp.Clear); break;
+ case 27 when !reverse: Note(ops, AreaInverse, AreaAttributeOp.Clear); break;
+ case 28 when !reverse: Note(ops, AreaInvisible, AreaAttributeOp.Clear); break;
}
}
+
+ foreach (var op in ops)
+ {
+ if (op != AreaAttributeOp.None)
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Folds one parameter's request into what is already asked of that attribute.
+ private static void Note(Span ops, int attribute, AreaAttributeOp op) =>
+ ops[attribute] = op is not AreaAttributeOp.Toggle
+ ? op
+ : ops[attribute] switch
+ {
+ AreaAttributeOp.None => AreaAttributeOp.Toggle,
+ AreaAttributeOp.Toggle => AreaAttributeOp.None,
+ AreaAttributeOp.Set => AreaAttributeOp.Clear,
+ _ => AreaAttributeOp.Set,
+ };
+
+ /// Applies the ops read by to one cell's rendition.
+ private static void ApplyAreaAttributeOps(ReadOnlySpan ops, ref AttributeData attributes)
+ {
+ switch (ops[AreaBold])
+ {
+ case AreaAttributeOp.Set: attributes.SetBold(true); break;
+ case AreaAttributeOp.Clear: attributes.SetBold(false); break;
+ case AreaAttributeOp.Toggle: attributes.SetBold(!attributes.IsBold()); break;
+ }
+
+ switch (ops[AreaUnderline])
+ {
+ case AreaAttributeOp.Set: attributes.SetUnderline(true); break;
+ case AreaAttributeOp.Clear: attributes.SetUnderline(false); break;
+ case AreaAttributeOp.Toggle: attributes.SetUnderline(!attributes.IsUnderline()); break;
+ }
+
+ switch (ops[AreaBlink])
+ {
+ case AreaAttributeOp.Set: attributes.SetBlink(true); break;
+ case AreaAttributeOp.Clear: attributes.SetBlink(false); break;
+ case AreaAttributeOp.Toggle: attributes.SetBlink(!attributes.IsBlink()); break;
+ }
+
+ switch (ops[AreaInverse])
+ {
+ case AreaAttributeOp.Set: attributes.SetInverse(true); break;
+ case AreaAttributeOp.Clear: attributes.SetInverse(false); break;
+ case AreaAttributeOp.Toggle: attributes.SetInverse(!attributes.IsInverse()); break;
+ }
+
+ switch (ops[AreaInvisible])
+ {
+ case AreaAttributeOp.Set: attributes.SetInvisible(true); break;
+ case AreaAttributeOp.Clear: attributes.SetInvisible(false); break;
+ case AreaAttributeOp.Toggle: attributes.SetInvisible(!attributes.IsInvisible()); break;
+ }
}
}