diff --git a/src/XTerm.NET.Tests/ChecksumReportTests.cs b/src/XTerm.NET.Tests/ChecksumReportTests.cs index 66713b9..ef7578c 100644 --- a/src/XTerm.NET.Tests/ChecksumReportTests.cs +++ b/src/XTerm.NET.Tests/ChecksumReportTests.cs @@ -50,13 +50,57 @@ public void A_rects_checksum_is_the_sum_of_its_characters() } [Fact] - public void A_cell_nothing_ever_wrote_counts_as_a_space() + public void A_run_of_blanks_counts_once_for_the_first_cell() { - // Erased and never-written alike: DEC terminals trim trailing blanks and esctest's client - // side reasons that away, but only if blanks come back as spaces rather than zeros. + // DEC terminals trim the blanks at the end of a row rather than counting them, and the + // first cell of the area is the documented exception -- it counts whatever it holds, which + // is what lets esctest read a written space back as 0x20 one cell at a time. var terminal = NewTerminal(); - Assert.Equal(Report(2, 3 * 0x20), Reply(terminal, $"{Esc}[2;0;2;1;2;3*y")); + Assert.Equal(Report(2, 0x20), Reply(terminal, $"{Esc}[2;0;2;1;2;3*y")); + } + + [Fact] + public void A_single_blank_cell_is_a_space() + { + // The shape esctest reads content back in: one cell at a time, expecting the character it + // put there. Trimming that unconditionally would answer zero for every space on screen. + var terminal = NewTerminal(); + + Assert.Equal(Report(8, 0x20), Reply(terminal, $"{Esc}[8;0;2;1;2;1*y")); + } + + [Fact] + public void A_blank_between_two_characters_counts() + { + // Only TRAILING blanks are trimmed. One with text still to come on its row is interior, + // and vttest computes its expectation the same way. + var terminal = NewTerminal(); + terminal.Write("a b"); + + Assert.Equal(Report(9, Sum("a b")), Reply(terminal, $"{Esc}[9;0;1;1;1;3*y")); + } + + [Fact] + public void Blanks_trailing_a_row_are_trimmed() + { + // The same three cells as above with the tail cut off: 'a', a blank, and nothing after it + // on the row. + var terminal = NewTerminal(); + terminal.Write("a b"); + + Assert.Equal(Report(10, 'a'), Reply(terminal, $"{Esc}[10;0;1;1;1;2*y")); + } + + [Fact] + public void A_rows_trailing_blanks_do_not_carry_into_the_next() + { + // Trimming is per row: the blanks after "hi" end with row 1 rather than being revived by + // the "there" on row 2. + var terminal = NewTerminal(cols: 8, rows: 2); + terminal.Write($"hi{Esc}[2;1Hthere"); + + Assert.Equal(Report(11, Sum("hi") + Sum("there")), Reply(terminal, $"{Esc}[11*y")); } [Fact] @@ -76,8 +120,9 @@ public void Coordinates_are_clamped_to_the_screen() var terminal = NewTerminal(cols: 10, rows: 3); terminal.Write("AB"); - // A rect hanging off every edge still answers, for what the screen actually holds. - Assert.Equal(Report(4, Sum("AB") + (3 * 10 - 2) * 0x20), + // A rect hanging off every edge still answers, for what the screen actually holds -- the + // blanks after "AB" trail their row and the two rows below it, so none of them count. + Assert.Equal(Report(4, Sum("AB")), Reply(terminal, $"{Esc}[4;0;1;1;99;99*y")); } @@ -87,7 +132,7 @@ public void Omitted_coordinates_mean_the_whole_screen() var terminal = NewTerminal(cols: 4, rows: 2); terminal.Write("hi"); - Assert.Equal(Report(5, Sum("hi") + 6 * 0x20), Reply(terminal, $"{Esc}[5*y")); + Assert.Equal(Report(5, Sum("hi")), Reply(terminal, $"{Esc}[5*y")); } [Fact] diff --git a/src/XTerm.NET.Tests/Common/CsiCommandExtensionsTests.cs b/src/XTerm.NET.Tests/Common/CsiCommandExtensionsTests.cs index 3161bb9..7b87fd0 100644 --- a/src/XTerm.NET.Tests/Common/CsiCommandExtensionsTests.cs +++ b/src/XTerm.NET.Tests/Common/CsiCommandExtensionsTests.cs @@ -77,10 +77,9 @@ public void ToCsiCommand_BareQ_IsDecllAndReturnsUnknown() /// /// '<' and '=' were never stripped, so they are recognised only where the map lists them -- - /// the Kitty keyboard pop and set forms, and nothing else. + /// the Kitty keyboard pop and set forms, the tertiary DA, and nothing else. /// [Theory] - [InlineData("=c")] [InlineData(" + /// DECRQDE, vttest menu 11.2.5 -> 6. The window IS the page, so the corner is 1;1 and there is + /// one page; the size is the same one CSI 18 t already reports in the dtterm dialect. + /// + [Fact] + public void Decrqde_ReportsTheDisplayedExtent() + { + var (terminal, replies) = Create(); + terminal.Write(Esc + "[\"v"); + Assert.Equal(Esc + "[24;80;1;1;1\"w", Assert.Single(replies)); + } + + [Fact] + public void Decrqde_IsSilentBelowVt300() + { + var (terminal, replies) = Create(); + terminal.Write(Esc + "[62\"p"); // DECSCL: VT200 + replies.Clear(); + terminal.Write(Esc + "[\"v"); + Assert.Empty(replies); + } + + /// + /// DECRQUPSS, vttest menu 11.2.5 -> 5. A UTF-8 terminal's supplemental set is ISO Latin-1, + /// a 96-character set, which is the Ps = 1 form and the designator 'A'. + /// + [Fact] + public void Decrqupss_ReportsIsoLatin1() + { + var (terminal, replies) = Create(); + terminal.Write(Esc + "[&u"); + Assert.Equal(Esc + "P1!uA" + Esc + "\\", Assert.Single(replies)); + } + + /// + /// DECRQTSR, vttest menu 11.2.5 -> 4 -> 2. There is no DECRSTS to consume a terminal state + /// report, so the answer is the invalid-request form rather than a payload nothing can restore + /// -- and rather than the silence a client blocks on, which is how DECRQSS already declines. + /// + [Fact] + public void Decrqtsr_DeclinesInsteadOfStayingSilent() + { + var (terminal, replies) = Create(); + terminal.Write(Esc + "[1$u"); + Assert.Equal(Esc + "P0$s" + Esc + "\\", Assert.Single(replies)); + } + + [Fact] + public void Decrqtsr_WithNoParameterAsksForNothing() + { + var (terminal, replies) = Create(); + terminal.Write(Esc + "[$u"); + Assert.Empty(replies); + } } diff --git a/src/XTerm.NET.Tests/InputHandlerTests.cs b/src/XTerm.NET.Tests/InputHandlerTests.cs index 7adcef4..9fc0d93 100644 --- a/src/XTerm.NET.Tests/InputHandlerTests.cs +++ b/src/XTerm.NET.Tests/InputHandlerTests.cs @@ -1171,7 +1171,7 @@ public void HandleCsi_DA_IgnoresNonZeroParameter(string identifier) } [Fact] - public void HandleCsi_DA_Tertiary_IsNotAnswered() + public void HandleCsi_DA_Tertiary_ReportsAZeroUnitId() { // Arrange var terminal = CreateTerminal(); @@ -1185,9 +1185,50 @@ public void HandleCsi_DA_Tertiary_IsNotAnswered() // Act - "=c" is the tertiary DA, asking for a unit ID handler.HandleCsi("=c", params_); - // Assert - there is no unit ID to report, and terminals without DECRPTUI stay quiet. - // Answering a question nobody asked is worse than silence: the program would read a DA - // reply where it expected DECRPTUI, while still waiting for the reply it did ask for. + // Assert - DECRPTUI, with the site code and serial number as the zeros xterm reports. + // There is no unit to identify, but vttest and anything else that asks decodes this and + // waits forever for the silence it used to get. + Assert.Equal("\u001bP!|00000000\u001b\\", receivedData); + } + + [Fact] + public void HandleCsi_DA_Tertiary_IsSilentBelowVt400() + { + // Arrange + var terminal = CreateTerminal(); + var handler = new InputHandler(terminal); + terminal.ConformanceLevel = 62; // VT200 + var params_ = new Params(); + params_.AddParam(0); + + string? receivedData = null; + terminal.DataReceived += (_, e) => receivedData = e.Data; + + // Act + handler.HandleCsi("=c", params_); + + // Assert - DECRPTUI is a VT420 control, and a program that put the terminal back to a + // VT200 level with DECSCL gets the silence a terminal of that vintage would have given it. + Assert.Null(receivedData); + } + + [Fact] + public void HandleCsi_DA_Tertiary_IgnoresAReply() + { + // Arrange + var terminal = CreateTerminal(); + var handler = new InputHandler(terminal); + var params_ = new Params(); + params_.AddParam(1); + + string? receivedData = null; + terminal.DataReceived += (_, e) => receivedData = e.Data; + + // Act + handler.HandleCsi("=c", params_); + + // Assert - a non-zero parameter is another terminal's reply arriving on our input, and + // answering it starts a ping-pong. The primary and secondary DA already refuse it. Assert.Null(receivedData); } diff --git a/src/XTerm.NET.Tests/OscSequenceTests.cs b/src/XTerm.NET.Tests/OscSequenceTests.cs index 8ab8277..6056b08 100644 --- a/src/XTerm.NET.Tests/OscSequenceTests.cs +++ b/src/XTerm.NET.Tests/OscSequenceTests.cs @@ -980,4 +980,93 @@ public void OscColorQueries_Sequential_AllRespond() Assert.Equal(3, responses.Count); Assert.All(responses, r => Assert.Contains("rgb:", r)); } + + /// + /// OSC 50 ; ? -- vttest menu 11.8.4.2. The emulator has no font; whatever draws its cells does, + /// so the question goes to the host the way OSC 52's does. + /// + [Fact] + public void OscFontQuery_AnswersWithTheNameTheHostGives() + { + var terminal = CreateTerminal(); + var responses = new List(); + terminal.DataReceived += (_, e) => responses.Add(e.Data); + terminal.FontQueryRequested += (_, e) => + { + e.FontName = "Cascadia Mono"; + e.Handled = true; + }; + + terminal.Write("\x1B]50;?\x1B\\"); + + Assert.Equal("\x1B]50;Cascadia Mono\x1B\\", Assert.Single(responses)); + } + + /// + /// The whole point of handling this at all: a host that will not say still produces a REPLY. + /// xterm answers a font query it cannot satisfy with a nameless OSC 50, and vttest reads + /// exactly that -- it skips entries whose reply carries no name. Silence leaves the asking + /// program waiting forever. + /// + [Fact] + public void OscFontQuery_AnswersNamelesslyWhenNoHostDoes() + { + var terminal = CreateTerminal(); + var responses = new List(); + terminal.DataReceived += (_, e) => responses.Add(e.Data); + + terminal.Write("\x1B]50;?\x1B\\"); + + Assert.Equal("\x1B]50\x1B\\", Assert.Single(responses)); + } + + [Fact] + public void OscFontQuery_AnswersNamelesslyForAMenuIndex() + { + // There is no font menu to index, so every indexed form is declined -- and never put to + // the host, which has no menu either. + var terminal = CreateTerminal(); + var responses = new List(); + var asked = false; + terminal.DataReceived += (_, e) => responses.Add(e.Data); + terminal.FontQueryRequested += (_, _) => asked = true; + + terminal.Write("\x1B]50;?#2\x1B\\"); + terminal.Write("\x1B]50;?+1\x1B\\"); + + Assert.False(asked); + Assert.Equal(new[] { "\x1B]50\x1B\\", "\x1B]50\x1B\\" }, responses); + } + + [Fact] + public void OscFontQuery_TerminatesTheReplyTheWayTheRequestWasTerminated() + { + var terminal = CreateTerminal(); + var responses = new List(); + terminal.DataReceived += (_, e) => responses.Add(e.Data); + + terminal.Write("\x1B]50;?\x07"); + + Assert.Equal("\x1B]50\x07", Assert.Single(responses)); + } + + [Fact] + public void OscFontSet_IsLeftToTheHostAndReportedUnrecognised() + { + // Setting a font is the host's business, so the terminal answers nothing and says it did + // not act -- a listener on OscReceived can, without having to work out whether it already + // had been. + var terminal = CreateTerminal(); + var responses = new List(); + TerminalEvents.OscReceivedEventArgs? received = null; + terminal.DataReceived += (_, e) => responses.Add(e.Data); + terminal.OscReceived += (_, e) => received = e; + + terminal.Write("\x1B]50;9x15\x1B\\"); + + Assert.Empty(responses); + Assert.NotNull(received); + Assert.False(received!.Recognized); + Assert.Equal("9x15", received.Data); + } } diff --git a/src/XTerm.NET.Tests/RectangleOpsTests.cs b/src/XTerm.NET.Tests/RectangleOpsTests.cs index 76ef8aa..e4e1618 100644 --- a/src/XTerm.NET.Tests/RectangleOpsTests.cs +++ b/src/XTerm.NET.Tests/RectangleOpsTests.cs @@ -1,3 +1,4 @@ +using XTerm.Buffer; using XTerm.Options; namespace XTerm.Tests; @@ -137,4 +138,167 @@ public void SelectiveErase_spares_DecscaProtected_cells_only() // DECSCA protection holds; the ISO guard belongs to the other erase family and does not. Assert.Equal(" CD ", Row(terminal, 0, 6)); } + + private static AttributeData AttrAt(Terminal t, int row, int col) => + t.Buffer.Lines[row]![col].Attributes; + + [Fact] + public void ChangeAttributes_sets_the_named_attributes_and_leaves_the_text_alone() + { + var terminal = NewTerminal(cols: 10, rows: 4); + terminal.Write("XXXXXXXXXX\r\nXXXXXXXXXX\r\nXXXXXXXXXX"); + terminal.Write($"{Esc}[2;2;2;4;1;4$r"); // DECCARA: bold + underline over row 2, cols 2-4 + + Assert.Equal("XXXXXXXXXX", Row(terminal, 1, 10)); // characters untouched + Assert.False(AttrAt(terminal, 1, 0).IsBold()); + Assert.True(AttrAt(terminal, 1, 1).IsBold()); + Assert.True(AttrAt(terminal, 1, 1).IsUnderline()); + Assert.True(AttrAt(terminal, 1, 3).IsBold()); + Assert.False(AttrAt(terminal, 1, 4).IsBold()); + Assert.False(AttrAt(terminal, 0, 1).IsBold()); // the row above is not in the area + } + + /// + /// DECSACE's default is the STREAM: the area runs from the top-left position to the bottom-right + /// one across whole intervening lines, not as a box. This is the setting that was accepted, + /// reported back by DECRQSS and then read by nothing, because the controls it governs did not + /// exist. + /// + [Fact] + public void ChangeAttributes_runs_as_a_stream_by_default() + { + var terminal = NewTerminal(cols: 10, rows: 4); + terminal.Write($"{Esc}[2;3;3;6;1$r"); // rows 2-3, cols 3..6 + + // Row 2 runs from its column to the end of the line. + Assert.False(AttrAt(terminal, 1, 1).IsBold()); + Assert.True(AttrAt(terminal, 1, 2).IsBold()); + Assert.True(AttrAt(terminal, 1, 9).IsBold()); + + // Row 3 runs from the start of the line to its column. + Assert.True(AttrAt(terminal, 2, 0).IsBold()); + Assert.True(AttrAt(terminal, 2, 5).IsBold()); + Assert.False(AttrAt(terminal, 2, 6).IsBold()); + } + + [Fact] + public void ChangeAttributes_confines_itself_to_the_rectangle_under_Decsace_2() + { + var terminal = NewTerminal(cols: 10, rows: 4); + terminal.Write($"{Esc}[2*x"); // DECSACE 2 -- rectangle + terminal.Write($"{Esc}[2;3;3;6;1$r"); + + foreach (var row in new[] { 1, 2 }) + { + Assert.False(AttrAt(terminal, row, 1).IsBold()); + Assert.True(AttrAt(terminal, row, 2).IsBold()); + Assert.True(AttrAt(terminal, row, 5).IsBold()); + Assert.False(AttrAt(terminal, row, 6).IsBold()); + } + } + + [Fact] + public void ChangeAttributes_0_clears_the_four_DEC_attributes_but_not_invisible() + { + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write($"{Esc}[1;4;5;7;8mXXXX"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;4;0$r"); + + var attributes = AttrAt(terminal, 0, 0); + Assert.False(attributes.IsBold()); + Assert.False(attributes.IsUnderline()); + Assert.False(attributes.IsBlink()); + Assert.False(attributes.IsInverse()); + + // xterm leaves invisible out of the SGR_MASK that parameter 0 covers; it has its own + // 8 and 28, which is the extension the documentation calls out. + Assert.True(attributes.IsInvisible()); + } + + [Fact] + public void ChangeAttributes_ignores_everything_outside_the_DEC_set() + { + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write("XXXX"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;4;31;3;1$r"); // red and italic are not DECCARA's + + Assert.True(AttrAt(terminal, 0, 0).IsBold()); + Assert.False(AttrAt(terminal, 0, 0).IsItalic()); + Assert.Equal(AttributeData.Default.Fg, AttrAt(terminal, 0, 0).Fg); + } + + [Fact] + public void ReverseAttributes_toggles_each_cell_from_what_it_already_had() + { + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write($"{Esc}[1mXX{Esc}[0mXX"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;4;1$t"); // DECRARA: reverse bold + + Assert.False(AttrAt(terminal, 0, 0).IsBold()); + Assert.False(AttrAt(terminal, 0, 1).IsBold()); + Assert.True(AttrAt(terminal, 0, 2).IsBold()); + Assert.True(AttrAt(terminal, 0, 3).IsBold()); + Assert.Equal("XXXX ", Row(terminal, 0, 6)); + } + + [Fact] + public void ReverseAttributes_0_reverses_the_four_together() + { + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write($"{Esc}[1;5mXX"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;2;0$t"); + + var attributes = AttrAt(terminal, 0, 0); + Assert.False(attributes.IsBold()); // was on + Assert.False(attributes.IsBlink()); // was on + Assert.True(attributes.IsUnderline()); // was off + Assert.True(attributes.IsInverse()); // was off + } + + /// + /// The resets say nothing under DECRARA -- reversing an attribute already covers both + /// directions -- so xterm reads 22, 24, 25, 27 and 28 only when setting. + /// + [Fact] + public void ReverseAttributes_ignores_the_reset_parameters() + { + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write($"{Esc}[1mXX"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;2;22$t"); + + Assert.True(AttrAt(terminal, 0, 0).IsBold()); + } + + [Fact] + public void ChangeAttributes_marks_both_halves_of_a_wide_character() + { + // The trailing half holds no character of its own, which is exactly the cell xterm's + // never-drawn test would skip. Skipping it here would leave one character disagreeing + // with itself about how it is drawn. + var terminal = NewTerminal(cols: 6, rows: 2); + terminal.Write("δΈ–"); + terminal.Write($"{Esc}[2*x{Esc}[1;1;1;2;1$r"); + + Assert.True(AttrAt(terminal, 0, 0).IsBold()); + Assert.True(AttrAt(terminal, 0, 1).IsBold()); + } + + /// + /// DECSACE survives a soft reset and not a hard one, which is how xterm clears it. It mattered + /// only once DECCARA and DECRARA read it: a stale rectangle setting turns the next program's + /// stream into a box. + /// + [Fact] + public void Decsace_survives_a_soft_reset_and_not_a_hard_one() + { + var terminal = NewTerminal(cols: 10, rows: 4); + + terminal.Write($"{Esc}[2*x{Esc}[!p"); // DECSACE 2, then DECSTR + terminal.Write($"{Esc}[2;3;3;6;1$r"); + Assert.False(AttrAt(terminal, 1, 9).IsBold()); // still a rectangle + + terminal.Write($"{Esc}c"); // RIS + terminal.Write($"{Esc}[2;3;3;6;1$r"); + Assert.True(AttrAt(terminal, 1, 9).IsBold()); // back to a stream + } } diff --git a/src/XTerm.NET/Common/CommandExtensions.cs b/src/XTerm.NET/Common/CommandExtensions.cs index f0b92a6..56174fa 100644 --- a/src/XTerm.NET/Common/CommandExtensions.cs +++ b/src/XTerm.NET/Common/CommandExtensions.cs @@ -54,6 +54,7 @@ public static class CsiCommandExtensions { "x", CsiCommand.RequestTerminalParameters }, // DECREQTPARM { "c", CsiCommand.DeviceAttributes }, // DA1 - primary { ">c", CsiCommand.DeviceAttributes }, // DA2 - secondary + { "=c", CsiCommand.DeviceAttributes }, // DA3 - tertiary, the unit ID { "d", CsiCommand.LinePositionAbsolute }, { "f", CsiCommand.CursorPosition }, // HVP - same as CUP { "g", CsiCommand.TabClear }, @@ -83,6 +84,8 @@ public static class CsiCommandExtensions { "!p", CsiCommand.SoftReset }, // DECSTR { "$|", CsiCommand.SetColumnsPerPage }, // DECSCPP { "$v", CsiCommand.CopyRectangularArea }, // DECCRA + { "$r", CsiCommand.ChangeAttributesRectangularArea }, // DECCARA + { "$t", CsiCommand.ReverseAttributesRectangularArea }, // DECRARA { "$x", CsiCommand.FillRectangularArea }, // DECFRA { "$z", CsiCommand.EraseRectangularArea }, // DECERA { "${", CsiCommand.SelectiveEraseRectangularArea }, // DECSERA @@ -91,6 +94,9 @@ public static class CsiCommandExtensions { "\"q", CsiCommand.SelectCharacterProtection }, // DECSCA { "\"p", CsiCommand.SelectConformanceLevel }, // DECSCL { "*x", CsiCommand.SelectAttributeChangeExtent }, // DECSACE + { "\"v", CsiCommand.RequestDisplayedExtent }, // DECRQDE + { "&u", CsiCommand.RequestUserPreferredSupplementalSet }, // DECRQUPSS + { "$u", CsiCommand.RequestTerminalStateReport }, // DECRQTSR { "$}", CsiCommand.SelectActiveStatusDisplay }, // DECSASD { "$~", CsiCommand.SelectStatusDisplayType }, // DECSSDT { "*|", CsiCommand.SetLinesPerScreen }, // DECSNLS diff --git a/src/XTerm.NET/Common/CsiCommand.cs b/src/XTerm.NET/Common/CsiCommand.cs index 6946427..883a4cb 100644 --- a/src/XTerm.NET/Common/CsiCommand.cs +++ b/src/XTerm.NET/Common/CsiCommand.cs @@ -116,7 +116,8 @@ public enum CsiCommand TabClear, /// - /// Device Attributes (CSI c is the primary request, CSI > c the secondary). + /// Device Attributes (CSI c is the primary request, CSI > c the secondary, CSI = c the + /// tertiary). /// DeviceAttributes, @@ -192,6 +193,12 @@ public enum CsiCommand /// DECCRA -- copy a rectangular area (CSI ... $ v). CopyRectangularArea, + /// DECCARA -- set SGR attributes over an area, leaving its characters alone (CSI Pt;Pl;Pb;Pr;Pm $ r). + ChangeAttributesRectangularArea, + + /// DECRARA -- toggle SGR attributes over an area (CSI Pt;Pl;Pb;Pr;Pm $ t). + ReverseAttributesRectangularArea, + /// DECFRA -- fill a rectangular area with a character (CSI Pch;Pt;Pl;Pb;Pr $ x). FillRectangularArea, @@ -234,6 +241,15 @@ public enum CsiCommand /// DECRQCRA -- request a checksum of a rectangular area (CSI Pid;Pp;Pt;Pl;Pb;Pr * y). RequestChecksumRectangularArea, + /// DECRQDE -- request the displayed extent (CSI " v); answered with DECRPDE. + RequestDisplayedExtent, + + /// DECRQUPSS -- request the user-preferred supplemental set (CSI & u); answered with DECAUPSS. + RequestUserPreferredSupplementalSet, + + /// DECRQTSR -- request a terminal state report (CSI Ps $ u); answered with DECTSR. + RequestTerminalStateReport, + /// /// Set Kitty keyboard protocol flags (CSI = Ps ; Pm u). /// diff --git a/src/XTerm.NET/Common/OscCommand.cs b/src/XTerm.NET/Common/OscCommand.cs index 2973adb..4965327 100644 --- a/src/XTerm.NET/Common/OscCommand.cs +++ b/src/XTerm.NET/Common/OscCommand.cs @@ -81,6 +81,14 @@ public enum OscCommand /// PointerShape = 22, + /// + /// Font operations (OSC 50), xterm. + /// Format: OSC 50 ; ? ST - which font is in use + /// OSC 50 ; ? #n ST - which font entry n is + /// OSC 50 ; name ST - set the font + /// + FontOps = 50, + /// /// Clipboard operations (OSC 52). /// Format: OSC 52 ; c ; data ST diff --git a/src/XTerm.NET/Events/TerminalEvents.cs b/src/XTerm.NET/Events/TerminalEvents.cs index 6d8277e..3de73cd 100644 --- a/src/XTerm.NET/Events/TerminalEvents.cs +++ b/src/XTerm.NET/Events/TerminalEvents.cs @@ -573,6 +573,27 @@ public WindowInfoRequestedEventArgs(WindowInfoRequest request) } } + /// + /// A request for the name of the font the terminal is displayed in (OSC 50 ; ? ST). The + /// emulator has no fonts -- whatever draws its cells does -- so this is the seam that asks + /// whatever does. + /// + /// + /// Answer by setting and . A handler that does not + /// answer, or that answers with an empty name, is a decline, and the terminal sends xterm's own + /// "no font to report" reply -- OSC 50 with no name -- rather than nothing. Silence is what a + /// client blocking on the report waits on forever; a reply saying the terminal will not say is + /// an answer it can act on. + /// + public class FontQueryEventArgs : EventArgs + { + /// True once the handler has supplied a name. + public bool Handled { get; set; } + + /// The font the terminal is drawn in, in whatever form the host names it. + public string? FontName { get; set; } + } + /// /// Buffer change event - fired when the active buffer switches. /// diff --git a/src/XTerm.NET/InputHandler.Csi.cs b/src/XTerm.NET/InputHandler.Csi.cs index e1e2203..3689f8e 100644 --- a/src/XTerm.NET/InputHandler.Csi.cs +++ b/src/XTerm.NET/InputHandler.Csi.cs @@ -771,6 +771,69 @@ private void RequestTerminalParameters(Params parameters) _terminal.RaiseDataReceived($"\u001b[{sol};1;1;128;128;1;0x"); } + /// + /// DECRQDE (CSI " v) -- reports the displayed extent as + /// CSI Ph ; Pw ; Pc ; Pr ; Pp " w: the page's height and width, the column and row of + /// the window's top-left corner within it, and the page number. + /// + /// + /// The window IS the page here, so the corner is always 1;1 and there is one page. That makes + /// this the DEC spelling of what CSI 18 t already answers in the dtterm dialect -- + /// a question this terminal has always known the answer to, and was the only one of the two + /// forms not answering. VT300 and up, as in xterm. + /// + private void RequestDisplayedExtent() + { + if (_terminal.ConformanceLevel < 63) + return; + + _terminal.RaiseDataReceived($"\u001b[{_terminal.Rows};{_terminal.Cols};1;1;1\"w"); + } + + /// + /// DECRQUPSS (CSI & u) -- reports the user-preferred supplemental set as + /// DCS Ps ! u <designator> ST, where Ps is 0 for a 94-character set and 1 for a + /// 96-character one. + /// + /// + /// The choice UPSS offers is DEC Supplemental Graphic or ISO Latin-1, and this terminal decodes + /// UTF-8: the supplemental half of Latin-1 is a pass-through here, and DEC Supplemental is not + /// reachable at all. So the answer is ISO Latin-1 -- A, a 96-character set -- which is + /// the same conclusion xterm reaches for the same reason. DECAUPSS, the assignment half, is not + /// implemented, so the default is the only value this can ever report; saying so is still worth + /// more than the silence a client waits on forever. VT300 and up. + /// + private void RequestUserPreferredSupplementalSet() + { + if (_terminal.ConformanceLevel < 63) + return; + + _terminal.RaiseDataReceived("\u001bP1!uA\u001b\\"); + } + + /// + /// DECRQTSR (CSI Ps $ u) -- the terminal state report, answered with the + /// cannot-report form DCS 0 $ s ST. + /// + /// + /// DECTSR serialises the whole terminal state into a payload DECRSTS restores later. This + /// terminal has no DECRSTS, and a terminal that cannot restore state has no business claiming + /// it can report it -- a report nothing can consume is a worse answer than an honest refusal, + /// because the client acts on it. + /// Refusing is not the same as saying nothing, though. DECRQSS already answers a request + /// it does not recognise with DCS 0 $ r ST rather than silence, and this is that same + /// shape one final character over: Ps = 0 is the invalid-request form of the same reply, and a + /// client reading it stops waiting. Ps = 0 or absent asks for nothing and gets nothing, which + /// is what the VT510 manual specifies for it. + /// + private void RequestTerminalStateReport(Params parameters) + { + if (parameters.GetParam(0, 0) == 0) + return; + + _terminal.RaiseDataReceived("\u001bP0$s\u001b\\"); + } + private void CursorForwardTab(Params parameters) => Tab(Math.Max(parameters.GetParam(0, 1), 1)); @@ -1651,14 +1714,31 @@ private void RestoreCursor() /// /// /// The sum follows the DEC/xterm convention esctest's default expects: each cell - /// contributes its character's codepoints, a cell that holds nothing contributes a SPACE -- - /// erased and never-written alike, which is also what lets DEC's trailing-blank trimming be - /// reasoned away by the client -- and the report carries the NEGATED total (0x10000 - sum), - /// which is what xterm sent before patch #279 and what esctest's default + /// contributes its character's codepoints, and the report carries the NEGATED total + /// (0x10000 - sum), which is what xterm sent before patch #279 and what esctest's default /// --xterm-checksum 0 undoes on its side. - /// Attributes deliberately contribute nothing. esctest compares a cell's checksum to - /// the bare codepoint of the character it expects, so a weight per attribute bit would fail - /// every assertion on styled text. + /// TRAILING BLANKS ARE TRIMMED, which is the part this used to get wrong. DEC terminals + /// drop the run of blanks at the end of a row rather than counting it, so a blank cell inside + /// an area is worth 0x20 only when something further along the same row follows it. Counting + /// every blank instead put this emulator exactly 95 spaces above what vttest's own DECRQCRA + /// test computes for the two rows it checks -- the whole of the discrepancy, and the whole of + /// the reason to trim. + /// The FIRST cell counted is added whatever it holds, blank or not. That exception is + /// not decoration: esctest reads content back one cell at a time and expects a space to come + /// back as 0x20, so a rule that trimmed unconditionally would answer zero for every space on + /// the screen. vttest builds its expectation with the same exception, in the same place. + /// xterm's xtermCheckRect is the reference for the rest of this and diverges here, + /// in a way worth naming. Its held-back run is only ever accumulated under + /// csNOTRIM -- the flag meaning "do not trim at all", under which the trimmed total is + /// then discarded -- so in its default mode the run is always empty and EVERY blank drops, + /// interior ones included. That is neither what DEC documents nor what vttest predicts; the two + /// readings agree on every screen either tool checks, and this follows the documented one. + /// Attributes deliberately contribute nothing, where xterm weights six of them into the + /// cell's value (bold 0x80, blink 0x40, inverse 0x20, underline 0x10, invisible 0x8, protected + /// 0x4). esctest compares a cell's checksum to the bare codepoint of the character it expects, + /// so a weight per attribute bit would fail every assertion on styled text. The visible + /// consequence is confined to the trimming above: xterm never trims a styled space, because its + /// value is no longer 0x20, and this trims it like any other. /// The page parameter is accepted and ignored: there is one screen. Coordinates are /// 1-based screen positions, clamped, whole screen when omitted. /// @@ -1676,6 +1756,13 @@ private void RequestChecksumRectangularArea(Params parameters) var right = Math.Min(_terminal.Cols, parameters.GetParam(5, _terminal.Cols - originX) + originX); var sum = 0; + // Blanks seen since the last counted character. They join the sum the moment something + // else on their row does, and are thrown away if the row ends first. + var pending = 0; + // Whether anything has been counted yet, which is the first-cell exemption above. A cell + // skipped outright does not spend it. + var first = true; + for (var row = top; row <= bottom; row++) { var line = _buffer.Lines[_buffer.YBase + row - 1]; @@ -1686,19 +1773,32 @@ private void RequestChecksumRectangularArea(Params parameters) { var cell = line[col - 1]; var content = cell.Content; + + // The trailing half of a wide character is a placeholder, not a blank: its + // character was already counted in full one cell to the left. A cell nothing has + // ever written to reads the same way, and neither adds anything. if (string.IsNullOrEmpty(content)) - { - // The trailing half of a wide character is a placeholder, not a blank: its - // character was already counted in full one cell to the left. - if (cell.Width == 0) - continue; - sum += 0x20; continue; - } + var value = 0; foreach (var ch in content) - sum += ch; + value += ch; + + if (first || value != 0x20) + { + sum += value + pending; + pending = 0; + } + else + { + pending += value; + } + + first = false; } + + // The row ended on blanks, so they were trailing ones. + pending = 0; } _terminal.RaiseDataReceived($"\u001bP{id}!~{(0x10000 - sum) & 0xFFFF:X4}\u001b\\"); diff --git a/src/XTerm.NET/InputHandler.Osc.cs b/src/XTerm.NET/InputHandler.Osc.cs index 1249629..b1a4b6a 100644 --- a/src/XTerm.NET/InputHandler.Osc.cs +++ b/src/XTerm.NET/InputHandler.Osc.cs @@ -724,6 +724,47 @@ private void AnswerPointerShapeQuery(string query) _terminal.RaiseDataReceived($"\u001b]22;{string.Join(",", answers)}\u001b\\"); } + /// + /// OSC 50 -- the font controls. Only the QUERY half is answered here. + /// + /// + /// A font query is a question about something the emulator does not own: whatever draws + /// the cells picks the font, exactly as whatever owns the clipboard answers OSC 52. So it is + /// put to the host through , and the host may decline + /// by not answering. + /// Declining is not silence, and that is the whole point of this method existing. xterm + /// answers a font query it cannot satisfy with a NAMELESS OSC 50 -- the reply with its + /// semicolon and name left off -- and vttest reads exactly that, skipping entries whose reply + /// carries no name. A client blocking on the report is told "not this one" instead of waiting + /// forever, which is the same argument that settled DECRQM and the pixel reports. + /// An INDEXED query -- ?#2, ?+1 and the rest, asking after an entry of + /// xterm's font menu -- is always answered namelessly: there is no font menu to index. And a + /// bare OSC 50 ; name SETS the font, which is the host's business and none of the + /// emulator's; it is reported as unrecognised so a listener on OscReceived can act on it + /// without having to work out whether the terminal already did. + /// + /// False for the set form, which this does not act on. + private bool HandleFontOps(string data) + { + if (!data.StartsWith('?')) + return false; + + // The name is asked for only when the query is bare. Anything trailing the '?' names a + // menu entry, and there is no menu. + string? name = null; + if (data.Length == 1) + { + var args = _terminal.RaiseFontQueryRequested(); + if (args.Handled && !string.IsNullOrEmpty(args.FontName)) + name = args.FontName; + } + + _terminal.RaiseDataReceived(name is null + ? $"\u001b]50{_terminal.OscReplyTerminator}" + : $"\u001b]50;{name}{_terminal.OscReplyTerminator}"); + return true; + } + private void HandleClipboard(string data) { var parts = data.Split(new[] { ';' }, 2); diff --git a/src/XTerm.NET/InputHandler.Rectangles.cs b/src/XTerm.NET/InputHandler.Rectangles.cs index 9e32c07..22e4a48 100644 --- a/src/XTerm.NET/InputHandler.Rectangles.cs +++ b/src/XTerm.NET/InputHandler.Rectangles.cs @@ -5,8 +5,9 @@ namespace XTerm; /// -/// The DEC rectangular-area operations: copy, fill and erase (DECCRA, DECFRA, DECERA). One file -/// because they share one coordinate discipline, spelled out on . +/// The DEC rectangular-area operations: copy, fill, erase and the two that change attributes +/// rather than characters (DECCRA, DECFRA, DECERA, DECSERA, DECCARA, DECRARA). One file because +/// they share one coordinate discipline, spelled out on . /// public partial class InputHandler { @@ -169,4 +170,116 @@ private void CopyRectangularArea(Params parameters) } } } + + /// + /// 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. + /// 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 + /// start of the line to its column, and every row between them runs whole. + /// Only the six attributes DEC defines are touched: 1 bold, 4 underline, 5 blink, + /// 7 inverse and their resets 22, 24, 25, 27, plus xterm's 8/28 for invisible. Parameter 0 + /// means the first four together -- NOT invisible, which xterm leaves out of its SGR_MASK -- + /// 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. + /// 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 + /// state to test for and the only cell that reads as empty is a wide character's second half. + /// Skipping THAT would leave a character's two halves disagreeing about their own rendition. + /// + private void MarkRectangularArea(Params parameters, bool reverse) + { + if (!TryReadRectangle(parameters, 0, out var top, out var left, out var bottom, out var right)) + return; + + var exact = _attributeChangeExtent == 2; + + for (var row = top; row <= bottom; row++) + { + var line = _buffer.Lines[_buffer.YBase + row]; + if (line is null) + continue; + + var from = exact || row == top ? left : 0; + var to = exact || row == bottom ? right : _terminal.Cols - 1; + + 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); + } + } + } + + /// + /// Applies the DECCARA/DECRARA attribute list starting at to one + /// cell's rendition. + /// + private static void ApplyAreaAttributes(Params parameters, int first, ref AttributeData attributes, bool reverse) + { + 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); + 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; + } + } + } } diff --git a/src/XTerm.NET/InputHandler.StoredModes.cs b/src/XTerm.NET/InputHandler.StoredModes.cs index 4dd0b47..83f7e69 100644 --- a/src/XTerm.NET/InputHandler.StoredModes.cs +++ b/src/XTerm.NET/InputHandler.StoredModes.cs @@ -50,16 +50,27 @@ public partial class InputHandler // DECHCCM because the hardware it coupled is gone. /// - /// Stored display SETTINGS, kept for the same reason as the stored modes: DECRQSS answers - /// them, and "recognised, at its default" beats a denial. Extent is DECSACE's -- the rect - /// operations read it when the standard grows teeth here; the status-display pair have no - /// status line to point at and never will. + /// DECSACE's extent, which DECCARA and DECRARA read to decide between a rectangle and a + /// stream, and which DECRQSS reports. /// private int _attributeChangeExtent; // DECSACE (* x) // 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. + /// + /// Puts DECSACE back to its default, for RIS. + /// + /// + /// Separate from , which the SOFT reset calls too. DECSACE + /// survives DECSTR on a real terminal and in xterm -- it is cleared in the full-reset branch + /// of ReallyReset and nowhere else -- and now that DECCARA and DECRARA read it, the + /// difference between the two resets is the difference between a program's rectangle landing + /// as a rectangle and landing as a stream. The status-display pair reset with the status line + /// itself, in the terminal that now owns them. + /// + internal void ResetAttributeChangeExtent() => _attributeChangeExtent = 0; + /// 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 e70fad4..650861f 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -716,6 +716,14 @@ public void HandleCsi(string identifier, Params parameters) CopyRectangularArea(parameters); break; + case CsiCommand.ChangeAttributesRectangularArea: + MarkRectangularArea(parameters, reverse: false); + break; + + case CsiCommand.ReverseAttributesRectangularArea: + MarkRectangularArea(parameters, reverse: true); + break; + case CsiCommand.FillRectangularArea: FillRectangularArea(parameters); break; @@ -805,6 +813,18 @@ public void HandleCsi(string identifier, Params parameters) RequestChecksumRectangularArea(parameters); break; + case CsiCommand.RequestDisplayedExtent: + RequestDisplayedExtent(); + break; + + case CsiCommand.RequestUserPreferredSupplementalSet: + RequestUserPreferredSupplementalSet(); + break; + + case CsiCommand.RequestTerminalStateReport: + RequestTerminalStateReport(parameters); + break; + case CsiCommand.DeviceStatusReport: DeviceStatusReport(parameters, isPrivate); break; @@ -1240,6 +1260,10 @@ public void HandleOsc(string data) HandlePointerShape(arg); break; + case OscCommand.FontOps: + recognized = HandleFontOps(arg); + break; + case OscCommand.Clipboard: HandleClipboard(arg); break; @@ -1559,6 +1583,18 @@ private void DeviceAttributes(string identifier, Params parameters) // "no cartridge ROM". _terminal.RaiseDataReceived(SecondaryDeviceAttributes); } + else if (identifier.StartsWith('=')) + { + // Tertiary DA: CSI = Pp c, answered with DECRPTUI -- DCS ! | <8 hex digits> ST, the + // "terminal unit ID". There is no unit to identify, so the site code and serial number + // are zeros, which is exactly what xterm reports and what vttest decodes and displays. + // + // VT400 and up only, as in xterm: DECRPTUI arrived with the VT420, and a program that + // has put the terminal into a VT100 or VT200 conformance level with DECSCL is entitled + // to the silence a terminal of that vintage would have given it. + if (_terminal.ConformanceLevel >= 64) + _terminal.RaiseDataReceived("\u001bP!|00000000\u001b\\"); + } else if (identifier.Length == 1) { // Primary DA: CSI ? Pl ; ... c, from the DECSCL operating level. @@ -1567,11 +1603,9 @@ private void DeviceAttributes(string identifier, Params parameters) // Any other prefix is left unanswered. "?c" is the one that used to go wrong: it is not the // secondary DA, but it sets isPrivate, so it was handed the secondary reply -- the answer to - // a question the program had not asked, while it was still waiting for the one it had. - // Neither it nor the tertiary DA, "=c", reaches this method any more: the lookup matches the - // whole identifier and only "c" and ">c" are listed, so both resolve to Unknown. Silence is - // the right outcome for the tertiary regardless: it asks for a unit ID this terminal does - // not have, and terminals without DECRPTUI say nothing. + // a question the program had not asked, while it was still waiting for the one it had. It + // does not reach this method any more: the lookup matches the whole identifier and "?c" is + // not listed, so it resolves to Unknown. } /// diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index 37a3758..66693d9 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -731,6 +731,18 @@ private void RaisePointerShapeChanged(string? before) /// public event EventHandler? WindowInfoRequested; + /// + /// Fired when a program asks which font the terminal is displayed in (OSC 50 ; ? ST). + /// + /// + /// The emulator has no font of its own; the host that draws its cells does. Answer by setting + /// and + /// . Declining -- by not subscribing, + /// or by leaving the name unset -- still sends a reply, xterm's nameless OSC 50, so the asking + /// program is told rather than left waiting. + /// + public event EventHandler? FontQueryRequested; + /// /// Fired when the active buffer is changed. /// @@ -1232,6 +1244,9 @@ public void Reset() // RIS had already put the behaviour back. The report and the behaviour now agree. _inputHandler.ResetStoredModes(); + // DECSACE, which a SOFT reset deliberately leaves alone. + _inputHandler.ResetAttributeChangeExtent(); + // And the charset designations, with the SO/SI shift state. InputHandler.ResetCharsets // existed for exactly this and was called from nowhere, so a program that designated line // drawing into G0 and died left the next one printing box characters for letters. @@ -1935,6 +1950,13 @@ internal void RaiseWindowRefreshed() => internal void RaiseWindowFullscreened() => WindowFullscreened?.Invoke(this, EventArgs.Empty); + internal TerminalEvents.FontQueryEventArgs RaiseFontQueryRequested() + { + var args = new TerminalEvents.FontQueryEventArgs(); + FontQueryRequested?.Invoke(this, args); + return args; + } + internal TerminalEvents.WindowInfoRequestedEventArgs RaiseWindowInfoRequested(WindowInfoRequest request) { var args = new TerminalEvents.WindowInfoRequestedEventArgs(request); @@ -2166,6 +2188,7 @@ public void Dispose() DataReceived = null; ClipboardWriteRequested = null; ClipboardReadRequested = null; + FontQueryRequested = null; CursorStyleChanged = null; SynchronizedOutputChanged = null; BufferChanged = null;