diff --git a/src/XTerm.NET.Tests/LeftRightMarginTests.cs b/src/XTerm.NET.Tests/LeftRightMarginTests.cs new file mode 100644 index 0000000..3b008b6 --- /dev/null +++ b/src/XTerm.NET.Tests/LeftRightMarginTests.cs @@ -0,0 +1,512 @@ +using XTerm.Options; +using Xunit; + +namespace XTerm.Tests; + +/// +/// DECSLRM -- left and right margins, and the DECLRMM mode (69) that turns them on. +/// +/// The margins themselves are the easy half. What makes the feature real is that every +/// operation which moves content honours them: wrapping, scrolling, IL/DL and ICH/DCH. A terminal +/// that reports the mode as supported and then scrolls the whole screen anyway is worse than one +/// that reports nothing, because an application asks before it relies on this. +/// +public class LeftRightMarginTests +{ + private const string Esc = "\u001b"; + + private static Terminal Fresh(int cols = 20, int rows = 6) + => new(new TerminalOptions { Cols = cols, Rows = rows }); + + /// A terminal with margins already set, stated 1-based as an application would. + private static Terminal WithMargins(int left = 4, int right = 9, int cols = 20, int rows = 6) + { + var t = Fresh(cols, rows); + t.Write($"{Esc}[?69h{Esc}[{left};{right}s"); + return t; + } + + private static string Row(Terminal terminal, int row = 0) + { + var line = terminal.Buffer.Lines[terminal.Buffer.YBase + row]!; + return string.Concat(Enumerable.Range(0, terminal.Cols).Select(c => line[c].Content)) + .TrimEnd('\0', ' '); + } + + // ---- the mode, and the sequence it unlocks ------------------------------------------------- + + /// + /// CSI s is Save Cursor until DECLRMM says otherwise. Getting this backwards would make an + /// application's margins silently save the cursor, or a save silently set margins. + /// + [Fact] + public void Without_the_mode_CSI_s_still_saves_the_cursor() + { + var terminal = Fresh(); + + terminal.Write($"{Esc}[3;5H{Esc}[3;9s{Esc}[1;1H{Esc}[u"); + + Assert.Equal(0, terminal.Buffer.ScrollLeft); + Assert.Equal(terminal.Cols - 1, terminal.Buffer.ScrollRight); + Assert.Equal(4, terminal.Buffer.X); + Assert.Equal(2, terminal.Buffer.Y); + } + + [Fact] + public void With_the_mode_CSI_s_sets_the_margins() + { + var terminal = WithMargins(left: 4, right: 9); + + Assert.Equal(3, terminal.Buffer.ScrollLeft); + Assert.Equal(8, terminal.Buffer.ScrollRight); + } + + /// Setting margins homes the cursor, as DECSTBM does. + [Fact] + public void Setting_margins_homes_the_cursor() + { + var terminal = Fresh(); + terminal.Write($"{Esc}[4;7H{Esc}[?69h{Esc}[4;9s"); + + Assert.Equal(0, terminal.Buffer.X); + Assert.Equal(0, terminal.Buffer.Y); + } + + /// Omitted parameters mean the extremes, so a bare CSI s under the mode widens out. + [Fact] + public void A_bare_sequence_widens_the_margins_again() + { + var terminal = WithMargins(); + + terminal.Write($"{Esc}[s"); + + Assert.Equal(0, terminal.Buffer.ScrollLeft); + Assert.Equal(terminal.Cols - 1, terminal.Buffer.ScrollRight); + } + + /// + /// A right margin at or left of the left one is refused rather than clamped: the old margins + /// stay, which the application can at least query, instead of a region it did not ask for. + /// + [Fact] + public void A_degenerate_pair_is_refused_and_leaves_the_old_margins() + { + var terminal = WithMargins(left: 4, right: 9); + + terminal.Write($"{Esc}[9;4s"); + + Assert.Equal(3, terminal.Buffer.ScrollLeft); + Assert.Equal(8, terminal.Buffer.ScrollRight); + } + + [Fact] + public void Turning_the_mode_off_widens_the_margins() + { + var terminal = WithMargins(); + + terminal.Write($"{Esc}[?69l"); + + Assert.Equal(0, terminal.Buffer.ScrollLeft); + Assert.Equal(terminal.Cols - 1, terminal.Buffer.ScrollRight); + } + + /// + /// Without a way to ask, a well-behaved application never uses the feature -- so DECRQM has to + /// answer for this mode, not only for the ones that came before it. + /// + [Fact] + public void The_mode_can_be_queried() + { + var terminal = Fresh(); + var replies = new List(); + terminal.DataReceived += (_, e) => replies.Add(e.Data); + + terminal.Write($"{Esc}[?69$p"); + terminal.Write($"{Esc}[?69h{Esc}[?69$p"); + + Assert.Equal(new[] { $"{Esc}[?69;2$y", $"{Esc}[?69;1$y" }, replies); + } + + // ---- the operations that make it real ------------------------------------------------------- + + [Fact] + public void Text_wraps_at_the_right_margin_and_resumes_at_the_left() + { + var terminal = WithMargins(left: 4, right: 9); // columns 3..8, six wide + + // Into the margins first. DECSLRM homes the cursor to column 1 of the SCREEN, not of the + // region, unless origin mode is on -- and a cursor outside the margins is not in the region, + // so it wraps at the screen edge like any other text. That is xterm's rule, and it is what + // stops a status line drawn outside a pane being folded into it. + terminal.Write($"{Esc}[1;4H"); + terminal.Write("abcdefghi"); + + Assert.Equal(" abcdef", Row(terminal, 0)); + Assert.Equal(" ghi", Row(terminal, 1)); + } + + /// + /// The batched writer bypasses the per-character wrap check, so it has to be bounded by the + /// margin itself. Without that it writes straight through and out the other side -- and only + /// when the fast path takes the write, which reads as an intermittent fault rather than a + /// missing case. + /// + [Fact] + public void The_batched_and_per_character_paths_agree_about_the_margin() + { + var batched = WithMargins(left: 4, right: 9); + batched.Write($"{Esc}[1;4H"); + batched.Write("abcdefghijkl"); + + var perCharacter = WithMargins(left: 4, right: 9); + perCharacter.UseRunPrinting = false; + perCharacter.Write($"{Esc}[1;4H"); + perCharacter.Write("abcdefghijkl"); + + Assert.Equal(Row(perCharacter, 0), Row(batched, 0)); + Assert.Equal(Row(perCharacter, 1), Row(batched, 1)); + Assert.Equal(" abcdef", Row(batched, 0)); + } + + /// And the same through the byte entry, which is a third writer again. + [Fact] + public void The_byte_entry_agrees_about_the_margin() + { + var terminal = WithMargins(left: 4, right: 9); + + terminal.Write(System.Text.Encoding.UTF8.GetBytes($"{Esc}[1;4Habcdefghi")); + + Assert.Equal(" abcdef", Row(terminal, 0)); + Assert.Equal(" ghi", Row(terminal, 1)); + } + + /// + /// The case the feature exists for: scrolling one pane of a side-by-side layout must leave the + /// other pane alone. This is what a whole-line scroll gets wrong. + /// + [Fact] + public void Scrolling_inside_the_margins_leaves_the_columns_outside_untouched() + { + var terminal = Fresh(cols: 12, rows: 4); + + terminal.Write("LLLmmmmmmRRR"); + terminal.Write($"{Esc}[2;1HLLLnnnnnnRRR"); + + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;4H"); + terminal.Write($"{Esc}[S"); + + Assert.Equal("LLLnnnnnnRRR", Row(terminal, 0)); + Assert.Equal("LLL RRR", Row(terminal, 1).PadRight(12)); + } + + [Fact] + public void Inserting_a_line_shifts_only_the_margin_columns() + { + var terminal = Fresh(cols: 12, rows: 4); + + terminal.Write("LLLmmmmmmRRR"); + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;4H{Esc}[L"); + + Assert.Equal("LLL RRR", Row(terminal, 0).PadRight(12)); + Assert.Equal(" mmmmmm", Row(terminal, 1)); + } + + /// + /// From outside the margin columns there is no region to shift, so IL does nothing. A cursor in + /// the right-hand pane shifting the left pane's lines is the corruption margins prevent. + /// + [Fact] + public void Inserting_a_line_from_outside_the_margins_does_nothing() + { + var terminal = Fresh(cols: 12, rows: 4); + + terminal.Write("LLLmmmmmmRRR"); + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;11H{Esc}[L"); + + Assert.Equal("LLLmmmmmmRRR", Row(terminal, 0)); + } + + [Fact] + public void Inserting_characters_stops_at_the_right_margin() + { + var terminal = Fresh(cols: 12, rows: 4); + + terminal.Write("LLLmmmmmmRRR"); + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;4H{Esc}[2@"); + + Assert.Equal("LLL mmmmRRR", Row(terminal, 0)); + } + + [Fact] + public void Deleting_characters_pulls_in_from_inside_the_margin_only() + { + var terminal = Fresh(cols: 12, rows: 4); + + terminal.Write("LLLmmmmmmRRR"); + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;4H{Esc}[2P"); + + Assert.Equal("LLLmmmm RRR", Row(terminal, 0)); + } + + /// Under origin mode the region is a box, so column 1 is the left margin. + [Fact] + public void Origin_mode_addresses_columns_from_the_left_margin() + { + var terminal = WithMargins(left: 4, right: 9); + + terminal.Write($"{Esc}[?6h{Esc}[1;1Hx"); + + Assert.Equal(" x", Row(terminal, 0)); + } + + // ---- and what has to survive --------------------------------------------------------------- + + [Fact] + public void A_resize_clamps_the_margins() + { + var terminal = WithMargins(left: 4, right: 15, cols: 20); + + terminal.Resize(8, terminal.Rows); + + Assert.Equal(3, terminal.Buffer.ScrollLeft); + Assert.Equal(7, terminal.Buffer.ScrollRight); + } + + /// + /// A resize that would leave the region degenerate widens it instead, rather than leaving a + /// region no write could land in. + /// + [Fact] + public void A_resize_past_the_left_margin_widens_them_again() + { + var terminal = WithMargins(left: 10, right: 15, cols: 20); + + terminal.Resize(4, terminal.Rows); + + Assert.Equal(0, terminal.Buffer.ScrollLeft); + Assert.Equal(3, terminal.Buffer.ScrollRight); + } + + [Fact] + public void A_full_reset_widens_the_margins_and_clears_the_mode() + { + var terminal = WithMargins(); + + terminal.Write($"{Esc}c"); + + Assert.Equal(0, terminal.Buffer.ScrollLeft); + Assert.Equal(terminal.Cols - 1, terminal.Buffer.ScrollRight); + Assert.False(terminal.LeftRightMarginMode); + } + + /// + /// With the mode off, nothing changes anywhere. This is the regression that matters most, since + /// margins are off for every application that has never heard of them. + /// + [Fact] + public void With_no_margins_set_everything_behaves_as_before() + { + var terminal = Fresh(cols: 8, rows: 3); + + terminal.Write("abcdefghij"); + + Assert.Equal("abcdefgh", Row(terminal, 0)); + Assert.Equal("ij", Row(terminal, 1)); + } + + /// + /// A box scroll neither sets nor clears any line's IsWrapped flag — including the wrap-driven + /// scroll at the bottom of the region. The flag is per LINE, and every line keeps its content + /// outside the margins; marking the bottom line wrapped would claim continuation for content + /// that never moved, and a later reflow would merge full lines an application laid out + /// separately. So the wrapped lines outside the region stay wrapped, and the region's own + /// lines stay unwrapped, no matter how much the box scrolls. + /// + [Fact] + public void A_box_scroll_leaves_every_IsWrapped_flag_alone() + { + var terminal = Fresh(cols: 8, rows: 6); + + // A genuinely wrapped pair of rows below the future region, made by autowrap. + terminal.Write($"{Esc}[5;1H"); + terminal.Write("0123456789"); + Assert.True(terminal.Buffer.Lines[terminal.Buffer.YBase + 5]!.IsWrapped, + "sanity: autowrap marked the continuation row"); + + // Margins over rows 1-4, columns 3-6; fill the box until it wrap-scrolls repeatedly. + terminal.Write($"{Esc}[?69h{Esc}[3;6s{Esc}[1;4r"); + terminal.Write($"{Esc}[1;3H"); + terminal.Write(new string('x', 30)); + + for (var row = 0; row < 4; row++) + Assert.False(terminal.Buffer.Lines[terminal.Buffer.YBase + row]!.IsWrapped, + $"row {row} is inside the box and must not become a continuation"); + Assert.True(terminal.Buffer.Lines[terminal.Buffer.YBase + 5]!.IsWrapped, + "the wrapped pair below the region is untouched by the box scrolling"); + + terminal.Write($"{Esc}[?69l{Esc}[r"); + } + + // ---- the pending-wrap boundary column ------------------------------------------------------ + + /// + /// A full-width line leaves the cursor at X == Cols, the pending-wrap state — and that is the + /// ORDINARY place for IL to run from, margins or not. Reading it as "outside the region" made + /// IL a silent no-op on the default path after any full-width line. + /// + [Fact] + public void Inserting_a_line_still_works_from_the_pending_wrap_state() + { + var terminal = Fresh(); + terminal.Write($"{Esc}[2;1Hbelow"); + terminal.Write($"{Esc}[1;1H{new string('A', terminal.Cols)}"); + + terminal.Write($"{Esc}[L"); + + Assert.Equal("", Row(terminal, 0)); + Assert.Equal(new string('A', terminal.Cols), Row(terminal, 1)); + Assert.Equal("below", Row(terminal, 2)); + } + + [Fact] + public void Deleting_a_line_still_works_from_the_pending_wrap_state() + { + var terminal = Fresh(); + terminal.Write($"{Esc}[2;1Hsecond"); + terminal.Write($"{Esc}[1;1H{new string('A', terminal.Cols)}"); + + terminal.Write($"{Esc}[M"); + + Assert.Equal("second", Row(terminal, 0)); + } + + /// + /// ScrollRight + 1 is two states with one column: the pending-wrap residue of filling the + /// region's last column, and a deliberate placement at the first column of the NEXT pane — an + /// ordinary cursor position in the layout this feature exists for. The buffer's PendingWrap + /// flag tells them apart: a deliberately placed cursor is outside the region, so writing there + /// stays there instead of wrapping back into the pane to its left. + /// + [Fact] + public void Writing_just_right_of_the_margin_does_not_wrap_into_the_pane() + { + var terminal = WithMargins(left: 4, right: 9); // zero-based columns 3..8 + + terminal.Write($"{Esc}[1;10Hx"); // column 9: the next pane's first column + + var line = terminal.Buffer.Lines[terminal.Buffer.YBase]!; + Assert.Equal("x", line[9].Content); + Assert.Equal(0, terminal.Buffer.Y); // no wrap happened... + Assert.False(terminal.Buffer.Lines[terminal.Buffer.YBase + 1]!.IsWrapped); // ...marked or otherwise + } + + // ---- ICH and DCH are bounded by BOTH margins ----------------------------------------------- + + /// + /// A cursor outside the margins on either side must make ICH/DCH do nothing. Only the right + /// side was guarded at first: from LEFT of the left margin, ICH shifted the left pane's + /// columns across the margin into the right pane, and DCH pulled the right pane's columns + /// back across — content crossing the exact boundary margins exist to seal. + /// + [Theory] + [InlineData("@", 2)] // ICH, cursor left of the left margin + [InlineData("@", 12)] // ICH, cursor right of the right margin + [InlineData("P", 2)] // DCH, left + [InlineData("P", 12)] // DCH, right + public void Insert_and_delete_chars_do_nothing_from_outside_the_margins(string op, int column) + { + var terminal = WithMargins(left: 4, right: 9); + terminal.Write($"{Esc}[1;1Habcdefghijklmnopqrst"); + + terminal.Write($"{Esc}[1;{column}H{Esc}[3{op}"); + + Assert.Equal("abcdefghijklmnopqrst", Row(terminal, 0)); + } + + // ---- relative cursor movement honours the margins ------------------------------------------ + + /// + /// CUF stops at the right margin when the cursor starts inside the region and at the screen + /// edge when it starts outside — in/out decides, not origin mode, as in xterm. An application + /// that queried DECRQM for 69 and moved within its pane with CUF must not end up in the + /// neighbouring one. + /// + [Fact] + public void CursorForward_stops_at_the_right_margin_from_inside() + { + var terminal = WithMargins(left: 4, right: 9); + terminal.Write($"{Esc}[1;5H{Esc}[200C"); + Assert.Equal(8, terminal.Buffer.X); + + terminal.Write($"{Esc}[1;12H{Esc}[200C"); + Assert.Equal(terminal.Cols - 1, terminal.Buffer.X); + } + + [Fact] + public void CursorBackward_stops_at_the_left_margin_from_inside() + { + var terminal = WithMargins(left: 4, right: 9); + terminal.Write($"{Esc}[1;7H{Esc}[200D"); + Assert.Equal(3, terminal.Buffer.X); + + terminal.Write($"{Esc}[1;2H{Esc}[200D"); + Assert.Equal(0, terminal.Buffer.X); + } + + // ---- carriage return and the left margin --------------------------------------------------- + + /// + /// CR goes to the LEFT MARGIN when the cursor is at or right of it, and to column 0 only + /// when the cursor is left of it — xterm’s rule, with origin mode not consulted. A cursor + /// inside the region cannot escape it leftward: a CRLF emitted by an application drawing + /// inside its pane must start the next line at the pane’s edge, not in the pane next door. + /// + [Fact] + public void CR_returns_to_the_left_margin_from_inside_the_region() + { + var terminal = WithMargins(left: 4, right: 9); + terminal.Write($"{Esc}[1;7H\r"); + Assert.Equal(3, terminal.Buffer.X); + } + + [Fact] + public void CR_returns_to_column_zero_from_left_of_the_margin() + { + var terminal = WithMargins(left: 4, right: 9); + terminal.Write($"{Esc}[1;2H\r"); + Assert.Equal(0, terminal.Buffer.X); + } + + /// + /// Everything that "returns the carriage" shares CR’s rule, as in xterm: NEL is Index plus + /// CR, and CNL/CPL are CUD/CUU plus CR. None of them consult origin mode. + /// + [Theory] + [InlineData("E")] // ESC E, NEL — written as CSI-free below + [InlineData("[E")] // CSI E, CNL + [InlineData("[F")] // CSI F, CPL + public void NEL_CNL_and_CPL_follow_the_CR_rule(string tail) + { + var terminal = WithMargins(left: 4, right: 9); + + terminal.Write($"{Esc}[2;7H{Esc}{tail}"); // from inside the region + Assert.Equal(3, terminal.Buffer.X); + + terminal.Write($"{Esc}[2;2H{Esc}{tail}"); // from left of the margin + Assert.Equal(0, terminal.Buffer.X); + } + + [Fact] + public void ConvertEol_returns_to_the_left_margin_too() + { + var terminal = new Terminal(new TerminalOptions { Cols = 20, Rows = 6, ConvertEol = true }); + terminal.Write($"{Esc}[?69h{Esc}[4;9s"); + terminal.Write($"{Esc}[1;7H\n"); + Assert.Equal(3, terminal.Buffer.X); + } +} diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index 982c9b0..b9a7220 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -16,6 +16,8 @@ public class TerminalBuffer private int _x; private int _scrollBottom; private int _scrollTop; + private int _scrollLeft; + private int _scrollRight; private int _cols; private int _rows; @@ -64,6 +66,23 @@ public int ViewportY public int ScrollTop => _scrollTop; public int ScrollBottom => _scrollBottom; + /// The leftmost column of the scrolling region. Zero unless DECSLRM narrowed it. + public int ScrollLeft => _scrollLeft; + + /// The rightmost column of the scrolling region. The last column unless DECSLRM narrowed it. + public int ScrollRight => _scrollRight; + + /// + /// True while the scrolling region spans every column, which is the ordinary case. + /// + /// + /// Worth testing before anything else, because a full-width region scrolls by moving whole LINES + /// through the ring -- which is what feeds the scrollback and what the line recycling depends on. + /// Narrowed margins cannot use that path at all: only part of each line moves, the rest stays, + /// and nothing is promoted to scrollback. Two implementations, and this decides between them. + /// + public bool MarginsAreFullWidth => _scrollLeft == 0 && _scrollRight >= _cols - 1; + public CircularList Lines => _lines; /// @@ -111,6 +130,8 @@ public TerminalBuffer(int cols, int rows, int scrollback, bool hasScrollback = t _x = 0; _scrollTop = 0; _scrollBottom = rows - 1; + _scrollLeft = 0; + _scrollRight = cols - 1; SavedCursorState = new SavedCursor(); // Initialize buffer with empty lines @@ -141,11 +162,39 @@ public BufferLine GetBlankLine(AttributeData attr, bool isWrapped = false) } /// - /// Scrolls the buffer up by a specified number of lines. - /// This matches xterm.js Buffer.scroll() behavior. + /// Scrolls the scrolling region up, dispatching on the margins: full-width margins move whole + /// lines through the ring, narrowed margins move only the margin columns as a box. /// + /// How many rows to scroll by. + /// + /// Whether the line this scroll makes room for continues the previous one. Honoured only on + /// the full-width path: a box scroll neither sets nor clears any line's flag, for the reasons + /// the body explains. + /// + /// + /// The full-width path matches xterm.js Buffer.scroll() — promotion to scrollback + /// included. None of that applies to the narrowed path: xterm.js has no left/right margins, + /// no lines move through the ring, and nothing reaches scrollback. + /// public void ScrollUp(int lines, bool isWrapped = false) { + // Decided here rather than at each call site. Every path that scrolls -- a wrap at the + // bottom of the region, LF, IND, DECSTBM's own scroll -- arrives through this, so putting + // the choice anywhere else means finding all of them and finding them again next time. + // + // isWrapped is DELIBERATELY not forwarded. It is a per-LINE flag -- "this line continues + // the previous one" -- and a margin scroll moves a column BOX, not lines: every line in + // the region keeps its content outside the margins, so marking the bottom line wrapped + // would claim continuation for content that never moved, and a later reflow would merge + // full lines an application laid out separately. There is no per-line value that can + // describe a box continuation, so the flags of the untouched outside content win, and a + // box scroll neither sets nor clears any line's flag. + if (!MarginsAreFullWidth) + { + ScrollMarginColumns(_scrollTop, _scrollBottom, lines, up: true, BlankFill()); + return; + } + for (int i = 0; i < lines; i++) { BufferLine newLine; @@ -236,11 +285,18 @@ public void ScrollUp(int lines, bool isWrapped = false) } /// - /// Scrolls the buffer down by a specified number of lines. - /// This is reverse scrolling within the scroll region. + /// Scrolls the scrolling region down — reverse scrolling — with the same dispatch as + /// : whole lines when the margins are full width, a column box when + /// they are narrowed. Nothing reaches scrollback in either direction. /// public void ScrollDown(int lines) { + if (!MarginsAreFullWidth) + { + ScrollMarginColumns(_scrollTop, _scrollBottom, lines, up: false, BlankFill()); + return; + } + for (int i = 0; i < lines; i++) { // Calculate absolute positions in the buffer @@ -354,6 +410,113 @@ public void ResetScrollRegion() { _scrollTop = 0; _scrollBottom = _rows - 1; + + // The columns too. RIS and DECSTR both come through here, and leaving margins in + // force across a full reset would hand the next application a region it never asked for and + // has no reason to check. + ResetLeftRightMargins(); + } + + /// + /// Sets the left and right margins (DECSLRM). Both are inclusive and zero-based. + /// + /// False when the pair is degenerate, in which case nothing is changed. + /// + /// A right margin at or left of the left one is refused rather than clamped. DEC requires the + /// region to be at least two columns wide, and clamping a nonsense pair into a legal one leaves + /// an application drawing into a region it did not ask for and cannot detect -- whereas ignoring + /// it leaves the previous margins in force, which the application can at least query. + /// + public bool SetLeftRightMargins(int left, int right) + { + left = Math.Clamp(left, 0, _cols - 1); + right = Math.Clamp(right, 0, _cols - 1); + + if (right <= left) + return false; + + _scrollLeft = left; + _scrollRight = right; + return true; + } + + /// Widens the margins back to the whole screen. + public void ResetLeftRightMargins() + { + _scrollLeft = 0; + _scrollRight = _cols - 1; + } + + /// + /// Moves the margin columns of rows .. by + /// rows, filling what it vacates. The COLUMNS are not parameters: + /// they are always the current left/right margins. Only the rows vary, and only because IL + /// and DL scroll from the cursor's row rather than from the top of the region — which is why + /// this is internal, not public: the row parameters are an implementation detail of its four + /// callers (, , IL and DL), not an API. + /// + /// + /// The narrowed-margin half of all four of those operations, and a different operation + /// rather than a parameter on them. A full-width scroll moves whole LINES through the ring: the + /// top line is promoted to scrollback and a blank one appended. Only part of each line moves + /// here, so the lines stay where they are and their cells are copied between them -- and nothing + /// reaches the scrollback, because half a line is not a line anyone could scroll back to. + /// Copying row by row in the direction of travel, so a region taller than the distance + /// moved does not overwrite what it has yet to read. + /// + internal void ScrollMarginColumns(int top, int bottom, int count, bool up, BufferCell fill) + { + if (count <= 0 || top > bottom) + return; + + var left = _scrollLeft; + var width = _scrollRight - _scrollLeft + 1; + if (width <= 0) + return; + + var rows = bottom - top + 1; + count = Math.Min(count, rows); + + if (up) + { + for (var row = top; row <= bottom - count; row++) + CopyMarginColumns(row + count, row, left, width); + + for (var row = bottom - count + 1; row <= bottom; row++) + FillMarginColumns(row, left, width, fill); + } + else + { + for (var row = bottom; row >= top + count; row--) + CopyMarginColumns(row - count, row, left, width); + + for (var row = top; row < top + count; row++) + FillMarginColumns(row, left, width, fill); + } + } + + /// The blank a scroll leaves behind, matching what the full-width path fills with. + private static BufferCell BlankFill() + { + var cell = BufferCell.Space; + cell.Attributes = AttributeData.Default; + return cell; + } + + private void CopyMarginColumns(int fromRow, int toRow, int left, int width) + { + var from = _lines[_yBase + fromRow]; + var to = _lines[_yBase + toRow]; + if (from is null || to is null) + return; + + to.CopyCellsFrom(from, left, left, width, false); + } + + private void FillMarginColumns(int row, int left, int width, BufferCell fill) + { + var line = _lines[_yBase + row]; + line?.Fill(fill, left, left + width); } /// @@ -449,6 +612,7 @@ public void Resize(int newCols, int newRows) } var oldRows = _rows; + var oldCols = _cols; _cols = newCols; _rows = newRows; @@ -462,10 +626,25 @@ public void Resize(int newCols, int newRows) } _scrollTop = Math.Min(_scrollTop, newRows - 1); + // The same for the columns. A right margin that reached the old edge follows the new one -- + // an application that asked for "everything" should keep getting everything -- and a + // narrower one is clamped in. If the clamp makes the pair degenerate, the margins go back to + // the whole screen rather than leaving a region no write could land in. + if (_scrollRight >= oldCols - 1) + _scrollRight = newCols - 1; + else + _scrollRight = Math.Min(_scrollRight, Math.Max(0, newCols - 1)); + + _scrollLeft = Math.Min(_scrollLeft, Math.Max(0, newCols - 1)); + + if (_scrollRight <= _scrollLeft) + ResetLeftRightMargins(); + // Clamp, not Min. Moving to the NEW column count was the point of this change, but dropping // the lower bound with it meant a negative cursor -- which SetCursorRaw exists to allow -- // survived the resize and left the buffer reporting an out-of-bounds position. _x = Math.Clamp(_x, 0, Math.Max(0, newCols - 1)); + PendingWrap = false; // The mirror case. A cursor below the new bottom is NOT simply clamped into place -- its // overflow is pushed into scrollback, so the cursor stays on the LINE it was on. Clamping // alone moved the cursor onto earlier content: shrink a window with a prompt at row 22 down @@ -798,21 +977,53 @@ private void RebuildWithInsertions( } /// - /// Sets the cursor position. + /// True while the cursor's position is the residue of PRINTING — which is the only way it + /// comes to rest one past the last column it wrote, the pending-wrap state. + /// + /// + /// Exists because that one-past column is otherwise two different states with one + /// representation: X == ScrollRight + 1 is both "just filled the region's last column, wrap + /// due at the margin" and "deliberately placed at the first column right of the margin" — an + /// ordinary place for a cursor in the split layouts DECSLRM exists for. xterm keeps a + /// separate wrap flag so its column is never ambiguous; this is that flag, mapped onto the + /// existing seam: is how printing advances the cursor and sets it, + /// is how everything else moves the cursor and clears it, matching + /// xterm's rule that any explicit movement cancels a pending wrap. When X is inside the + /// margins the flag is meaningless and harmlessly stale; only the boundary column reads it. + /// + public bool PendingWrap { get; private set; } + + /// + /// Moves the cursor to the line’s start as CR defines it: the LEFT MARGIN when the cursor is + /// at or right of it, column 0 when the cursor is left of it. xterm’s rule (CarriageReturn in + /// charproc.c), independent of origin mode — a cursor inside the region cannot escape it + /// leftward, and one already left of the margin was never in the region to begin with. Every + /// operation that “returns the carriage” — CR itself, NEL, CNL/CPL, and a line feed under + /// ConvertEol — routes through this so none of them can disagree about where a line starts. + /// Clears the pending wrap, as any deliberate movement does. + /// + public void CarriageReturn() => SetCursor(_x < _scrollLeft ? 0 : _scrollLeft, _y); + + /// + /// Sets the cursor position — the deliberate, clamped move every cursor-addressing sequence + /// uses, which is why it cancels a pending wrap. /// public void SetCursor(int x, int y) { _x = Math.Clamp(x, 0, _cols - 1); _y = Math.Clamp(y, 0, _rows - 1); + PendingWrap = false; } /// - /// Moves the cursor to the specified position without any clamping. + /// Moves the cursor without clamping — the print-path move, which is what may leave X one + /// past the last written column and therefore sets . /// public void SetCursorRaw(int x, int y) { _x = x; _y = y; + PendingWrap = true; } public string PrintViewport() diff --git a/src/XTerm.NET/Common/TerminalMode.cs b/src/XTerm.NET/Common/TerminalMode.cs index 4fdf9fc..4e85e91 100644 --- a/src/XTerm.NET/Common/TerminalMode.cs +++ b/src/XTerm.NET/Common/TerminalMode.cs @@ -97,6 +97,12 @@ public enum TerminalMode /// Backarrow Key Mode (DECBKM). /// BackspaceKey = 67, + + /// + /// DECLRMM — left and right margin mode. While set, CSI Pl ; Pr s is DECSLRM and sets the + /// margins; while reset, that same sequence is Save Cursor. + /// + LeftRightMargin = 69, /// /// Bracketed Paste Mode. diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index 000f668..29ea02e 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -232,20 +232,28 @@ public void Print(string data) // Handle autowrap - if (_buffer.X >= _terminal.Cols) + if (_buffer.X > WrapLimit()) { if (_terminal.Options.Wraparound) { + // Only a FULL-WIDTH wrap marks the next line as a continuation. IsWrapped is a + // per-line flag, and a wrap inside the margin box continues the box, not the line: + // content outside the margins on the next row was never part of this text, and a + // reflow that believed the flag would merge lines an application laid out + // separately. Decided before the cursor moves, because the answer depends on + // where the wrap happened. + var lineWrap = WrapLimit() == _terminal.Cols - 1 && WrapHome() == 0; if (_buffer.Y == _buffer.ScrollBottom) { - _buffer.SetCursor(0, _buffer.Y); + _buffer.SetCursor(WrapHome(), _buffer.Y); _buffer.ScrollUp(1, true); } else { - _buffer.SetCursor(0, _buffer.Y + 1); + _buffer.SetCursor(WrapHome(), _buffer.Y + 1); } - _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; + if (lineWrap) + _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; } else { @@ -299,8 +307,11 @@ public void Print(string data) // Handle wide characters if (width == 2) { - // Set following cell as a spacer - if (_buffer.X + 1 < _terminal.Cols) + // Set following cell as a spacer, bounded by the right MARGIN rather than the screen -- + // otherwise a double-width character sitting on the last column of a region plants its + // spacer in the pane next door. Identical to the old test when no margins are set, since + // the limit is then the last column. + if (_buffer.X + 1 <= WrapLimit()) { var spacer = BufferCell.Empty; spacer.Attributes = _curAttr; @@ -429,29 +440,37 @@ internal void PrintAsciiRun(ReadOnlySpan data) while (!data.IsEmpty) { - if (_buffer.X >= _terminal.Cols) + if (_buffer.X > WrapLimit()) { if (!_terminal.Options.Wraparound) return; + // Full-width wraps only, as in Print: a wrap inside the margin box continues + // the box, not the line. + var lineWrap = WrapLimit() == _terminal.Cols - 1 && WrapHome() == 0; if (_buffer.Y == _buffer.ScrollBottom) { - _buffer.SetCursor(0, _buffer.Y); + _buffer.SetCursor(WrapHome(), _buffer.Y); _buffer.ScrollUp(1, true); } else { - _buffer.SetCursor(0, _buffer.Y + 1); + _buffer.SetCursor(WrapHome(), _buffer.Y + 1); } - _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; + if (lineWrap) + _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; } var line = _buffer.Lines[_buffer.Y + _buffer.YBase]; if (line == null) return; - var take = Math.Min(_terminal.Cols - _buffer.X, data.Length); + // Bounded by the right MARGIN, not the screen. A batched run bypasses the per-character + // wrap check above, so without this it writes straight through the margin and out the + // other side -- and only when the fast path takes the write, which is the difference + // that reads as an intermittent fault rather than a missing case. + var take = Math.Min(WrapLimit() + 1 - _buffer.X, data.Length); line.SetSingleWidthRun(_buffer.X, data[..take], _curAttr); // This path bypasses Print, so it keeps the link bookkeeping itself -- otherwise a link @@ -499,29 +518,34 @@ internal void PrintAsciiRun(string data, int start, int count) { // Autowrap, matching Print. The cursor is allowed to rest one past the last column, so // the wrap is resolved here rather than when the previous character was written. - if (_buffer.X >= _terminal.Cols) + if (_buffer.X > WrapLimit()) { if (!_terminal.Options.Wraparound) return; // printing past the edge is discarded, as in Print + // Full-width wraps only, as in Print: a wrap inside the margin box continues + // the box, not the line. + var lineWrap = WrapLimit() == _terminal.Cols - 1 && WrapHome() == 0; if (_buffer.Y == _buffer.ScrollBottom) { - _buffer.SetCursor(0, _buffer.Y); + _buffer.SetCursor(WrapHome(), _buffer.Y); _buffer.ScrollUp(1, true); } else { - _buffer.SetCursor(0, _buffer.Y + 1); + _buffer.SetCursor(WrapHome(), _buffer.Y + 1); } - _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; + if (lineWrap) + _buffer.Lines[_buffer.Y + _buffer.YBase]!.IsWrapped = true; } var line = _buffer.Lines[_buffer.Y + _buffer.YBase]; if (line == null) return; - var take = Math.Min(_terminal.Cols - _buffer.X, remaining); + // As above: the margin bounds the batch, or the fast path leaks past it. + var take = Math.Min(WrapLimit() + 1 - _buffer.X, remaining); line.SetSingleWidthRun(_buffer.X, data.AsSpan(pos, take), _curAttr); // As above: bypassing Print means keeping the link bookkeeping here as well. @@ -826,7 +850,14 @@ public void HandleCsi(string identifier, Params parameters) break; case CsiCommand.SaveCursorAnsi: - SaveCursorAnsi(); + // CSI s is two sequences sharing a final character, and the mode decides which. + // With DECLRMM set it is DECSLRM; without it, Save Cursor. This is the one place in + // the dispatch where that is true, and getting it backwards would make an + // application's margins silently save the cursor instead. + if (_terminal.LeftRightMarginMode) + SetLeftRightMargins(parameters); + else + SaveCursorAnsi(); break; case CsiCommand.RestoreCursorAnsi: @@ -2609,30 +2640,43 @@ private void CursorDown(Params parameters) private void CursorForward(Params parameters) { var count = Math.Max(parameters.GetParam(0, 1), 1); - _buffer.SetCursor(Math.Min(_buffer.X + count, _terminal.Cols - 1), _buffer.Y); + // Stops at the right margin when the cursor starts inside the region, the screen edge + // when it starts outside — in/out decides, not origin mode, as in xterm. Without the + // bound, CSI 200 C walks the cursor out of its pane and the next write lands in the + // neighbouring one. (Full-width margins make the two limits the same column.) + var limit = CursorInMarginColumns() ? _buffer.ScrollRight : _terminal.Cols - 1; + _buffer.SetCursor(Math.Min(_buffer.X + count, limit), _buffer.Y); } private void CursorBackward(Params parameters) { var count = Math.Max(parameters.GetParam(0, 1), 1); - _buffer.SetCursor(Math.Max(_buffer.X - count, 0), _buffer.Y); + // The mirror of CursorForward: the left margin stops a cursor that starts inside. + var home = CursorInMarginColumns() ? _buffer.ScrollLeft : 0; + _buffer.SetCursor(Math.Max(_buffer.X - count, home), _buffer.Y); } private void CursorNextLine(Params parameters) { + // xterm implements CNL as CUD then CR, so the column is CR’s: the left margin when the + // cursor is at or right of it, column 0 when it is left of it — origin mode is not + // consulted. The row move cannot change X, so the CR sees the starting column. var count = Math.Max(parameters.GetParam(0, 1), 1); - _buffer.SetCursor(0, Math.Min(_buffer.Y + count, _terminal.Rows - 1)); + _buffer.SetCursor(_buffer.X, Math.Min(_buffer.Y + count, _terminal.Rows - 1)); + _buffer.CarriageReturn(); } private void CursorPrecedingLine(Params parameters) { + // CPL is CUU then CR, mirroring CursorNextLine. var count = Math.Max(parameters.GetParam(0, 1), 1); - _buffer.SetCursor(0, Math.Max(_buffer.Y - count, 0)); + _buffer.SetCursor(_buffer.X, Math.Max(_buffer.Y - count, 0)); + _buffer.CarriageReturn(); } private void CursorCharAbsolute(Params parameters) { - var col = Math.Max(parameters.GetParam(0, 1), 1) - 1; + var col = GetAbsoluteCursorCol(Math.Max(parameters.GetParam(0, 1), 1) - 1); _buffer.SetCursor(col, _buffer.Y); } @@ -2641,6 +2685,7 @@ private void CursorPosition(Params parameters) var row = Math.Max(parameters.GetParam(0, 1), 1) - 1; var col = Math.Max(parameters.GetParam(1, 1), 1) - 1; row = GetAbsoluteCursorRow(row); + col = GetAbsoluteCursorCol(col); _buffer.SetCursor(col, row); } @@ -2751,13 +2796,88 @@ private void RepeatPrecedingCharacter(Params parameters) Print(text); } + /// + /// Whether the cursor is inside the margin columns — the ONE in/out answer every + /// column-sensitive operation shares, so no two of them can disagree about the same column. + /// + /// + /// The boundary column is the subtle part. X == ScrollRight + 1 is two different states: the + /// pending-wrap residue of filling the region's last column (INSIDE — the wrap is due at the + /// margin), and a deliberate placement at the first column right of the margin (OUTSIDE — an + /// ordinary cursor position in the split layouts this feature exists for). The buffer's + /// flag is what tells them apart; deciding by + /// position alone either wrapped the next pane's first column into this one, or ran text + /// straight through the margin at exactly the moment the wrap was due. + /// + private bool CursorInMarginColumns() + => _buffer.X >= _buffer.ScrollLeft + && _buffer.X <= _buffer.ScrollRight + (_buffer.PendingWrap ? 1 : 0); + + /// + /// The last column a write may land on before it wraps. + /// + /// + /// The right margin, not the screen edge — that is what makes text stay inside its pane. But + /// only for a cursor already INSIDE the margins: a cursor parked to the right of them is not in + /// the region at all, and wrapping it at the margin would drag it into a pane it was never in. + /// xterm draws the same distinction, and it is the reason this is a method rather than a field. + /// + private int WrapLimit() + { + if (_buffer.MarginsAreFullWidth || !CursorInMarginColumns()) + return _terminal.Cols - 1; + + return _buffer.ScrollRight; + } + + /// The column a wrapped line begins on: the left margin, for the same reason. + private int WrapHome() + { + if (_buffer.MarginsAreFullWidth || !CursorInMarginColumns()) + return 0; + + return _buffer.ScrollLeft; + } + + /// + /// Whether the cursor is inside the scrolling region — the box, not just the band of rows. + /// + /// + /// IL and DL do nothing from outside it. With margins that has to include the columns: a cursor + /// in the right-hand pane of a split layout is outside the left pane's region, and shifting the + /// left pane's lines from there is the exact corruption margins exist to prevent. The column + /// test goes through so the pending-wrap state counts as + /// inside — with no margins at all, a cursor resting at X == Cols after a full-width line is + /// the ORDINARY place for IL/DL to run from, and reading it as outside made them no-ops on the + /// default path. + /// + private bool InsideScrollRegion() + => _buffer.Y >= _buffer.ScrollTop && _buffer.Y <= _buffer.ScrollBottom + && CursorInMarginColumns(); + + /// A blank carrying the current attributes, which is what BCE fills with. + private BufferCell BlankCell() + { + var cell = BufferCell.Space; + cell.Attributes = _curAttr; + return cell; + } + private void InsertLines(Params parameters) { var count = Math.Max(parameters.GetParam(0, 1), 1); - // Only works in scroll region - if (_buffer.Y < _buffer.ScrollTop || _buffer.Y > _buffer.ScrollBottom) + if (!InsideScrollRegion()) return; + // Narrowed margins move only their own columns, so the lines stay put and their cells are + // copied between them. Splicing whole lines here would drag the columns OUTSIDE the region + // along with them, which is the side-by-side layout tearing itself apart. + if (!_buffer.MarginsAreFullWidth) + { + _buffer.ScrollMarginColumns(_buffer.Y, _buffer.ScrollBottom, count, up: false, BlankCell()); + return; + } + for (int i = 0; i < count; i++) { _buffer.Lines.Splice(_buffer.YBase + _buffer.ScrollBottom, 1); @@ -2769,10 +2889,15 @@ private void InsertLines(Params parameters) private void DeleteLines(Params parameters) { var count = Math.Max(parameters.GetParam(0, 1), 1); - // Only works in scroll region - if (_buffer.Y < _buffer.ScrollTop || _buffer.Y > _buffer.ScrollBottom) + if (!InsideScrollRegion()) return; + if (!_buffer.MarginsAreFullWidth) + { + _buffer.ScrollMarginColumns(_buffer.Y, _buffer.ScrollBottom, count, up: true, BlankCell()); + return; + } + for (int i = 0; i < count; i++) { _buffer.Lines.Splice(_buffer.Y + _buffer.YBase, 1); @@ -2788,14 +2913,20 @@ private void InsertChars(Params parameters) if (line == null) return; - // Shift cells right from cursor position + // The MARGINS bound this, not the screen — both of them. Shifting past the right margin + // would push characters out of one pane and into the next; running from a cursor LEFT of + // the left margin shifts the neighbouring pane's columns across it from outside, the same + // corruption from the other side. Outside the region on either side, ICH does nothing. + var right = _buffer.ScrollRight; + if (_buffer.X > right || _buffer.X < _buffer.ScrollLeft) + return; + + count = Math.Min(count, right - _buffer.X + 1); + line.CopyCellsFrom(line, _buffer.X, _buffer.X + count, - _terminal.Cols - _buffer.X - count, false); + right - _buffer.X - count + 1, false); - // Blank the inserted cells at cursor position - var emptyCell = BufferCell.Space; - emptyCell.Attributes = _curAttr; - line.Fill(emptyCell, _buffer.X, Math.Min(_buffer.X + count, _terminal.Cols)); + line.Fill(BlankCell(), _buffer.X, Math.Min(_buffer.X + count, right + 1)); } private void DeleteChars(Params parameters) @@ -2805,17 +2936,20 @@ private void DeleteChars(Params parameters) if (line == null) return; - // Limit count to remaining characters on line - var remaining = _terminal.Cols - _buffer.X; - count = Math.Min(count, remaining); + // As with ICH, the margins are the edges — what is pulled in comes from inside the + // region, the blanks appear at the margin rather than the screen edge, and a cursor + // outside the region on EITHER side does nothing rather than dragging the next pane's + // columns across the boundary. + var right = _buffer.ScrollRight; + if (_buffer.X > right || _buffer.X < _buffer.ScrollLeft) + return; + + count = Math.Min(count, right - _buffer.X + 1); line.CopyCellsFrom(line, _buffer.X + count, _buffer.X, - _terminal.Cols - _buffer.X - count, false); + right - _buffer.X - count + 1, false); - // Fill vacated cells at right edge with current attributes (BCE) - var emptyCell = BufferCell.Space; - emptyCell.Attributes = _curAttr; - line.Fill(emptyCell, _terminal.Cols - count, _terminal.Cols); + line.Fill(BlankCell(), right - count + 1, right + 1); } private void EraseChars(Params parameters) @@ -3271,6 +3405,30 @@ private int HandleExtendedColor(Params parameters, int index, bool isForeground) return index; } + /// + /// DECSLRM (CSI Pl ; Pr s) — set the left and right margins of the scrolling region. + /// + /// + /// Only reachable while DECLRMM is set; see the dispatch above for why. + /// Omitted parameters mean the extremes, so a bare CSI s under the mode widens the + /// margins back to the whole screen rather than doing nothing. + /// The cursor goes home afterwards, as it does for DECSTBM. A cursor left outside the new + /// region is the thing that makes the next write land somewhere the application did not choose. + /// + private void SetLeftRightMargins(Params parameters) + { + var left = Math.Max(parameters.GetParam(0, 1), 1) - 1; + var right = Math.Max(parameters.GetParam(1, _terminal.Cols), 1) - 1; + + // A degenerate pair is refused outright, and then so is the cursor move: DEC leaves the old + // margins in force, and homing the cursor to a region that was not set would be a visible + // effect from a sequence that had none. + if (!_buffer.SetLeftRightMargins(left, right)) + return; + + MoveCursorToHome(); + } + private void SetScrollRegion(Params parameters) { var top = Math.Max(parameters.GetParam(0, 1), 1) - 1; @@ -3292,8 +3450,30 @@ private int GetAbsoluteCursorRow(int row) private void MoveCursorToHome() { + // Home is the top-left of the SCROLLING REGION under origin mode, which with margins is a + // box rather than a band -- so the column matters as well as the row. var row = _terminal.OriginMode ? _buffer.ScrollTop : 0; - _buffer.SetCursor(0, row); + var col = _terminal.OriginMode ? _buffer.ScrollLeft : 0; + _buffer.SetCursor(col, row); + } + + /// + /// Turns a column an application asked for into an absolute one, honouring origin mode. + /// + /// + /// The column twin of . Under origin mode an application + /// addresses the region rather than the screen, so column 1 is the left margin and nothing it + /// asks for can land outside the box. + /// + private int GetAbsoluteCursorCol(int col) + { + if (_terminal.OriginMode) + { + long absolute = (long)_buffer.ScrollLeft + col; + return (int)Math.Clamp(absolute, _buffer.ScrollLeft, _buffer.ScrollRight); + } + + return Math.Clamp(col, 0, Math.Max(0, _terminal.Cols - 1)); } private void WindowManipulation(Params parameters) @@ -3517,12 +3697,13 @@ private void WindowManipulation(Params parameters) /// This is how an application finds out whether synchronized output is worth using: it /// asks, and a terminal that says nothing is one that does not support the query. Emitting the /// mode without answering for it would leave well-behaved applications never using it. - /// Deliberately answers for 2026 alone. The reply codes distinguish "set" and "reset" from - /// "not recognised", and this terminal keeps mode state as individual properties rather than a - /// registry — so answering for everything would mean a switch mapping every mode back to its - /// property, and getting one wrong tells an application a feature is missing when it is not. - /// Staying silent for the rest is exactly the behaviour before this change, so nothing regresses - /// while the one mode that needs an answer gets a correct one. + /// Deliberately answers only for the modes an application changes its behaviour on — + /// 2026 (synchronized output) and 69 (DECSLRM). The reply codes distinguish "set" and "reset" + /// from "not recognised", and this terminal keeps mode state as individual properties rather + /// than a registry — so answering for everything would mean a switch mapping every mode back to + /// its property, and getting one wrong tells an application a feature is missing when it is + /// not. Staying silent for the rest is exactly the behaviour before these modes were added, so + /// nothing regresses while the modes that need an answer get correct ones. /// private void HandleRequestMode(Params parameters, bool isPrivate) { @@ -3530,11 +3711,26 @@ private void HandleRequestMode(Params parameters, bool isPrivate) return; var mode = parameters.GetParam(0, 0); - if (mode != (int)TerminalMode.SynchronizedOutput) - return; // DECRPM: 1 = set, 2 = reset. - var state = _terminal.SynchronizedOutput ? 1 : 2; + int state; + switch ((TerminalMode)mode) + { + case TerminalMode.SynchronizedOutput: + state = _terminal.SynchronizedOutput ? 1 : 2; + break; + + // Worth answering, because an application that cannot ask will not use the feature: the + // whole point of DECSLRM is a layout that behaves differently when margins are available, + // and a well-behaved one checks before relying on them. + case TerminalMode.LeftRightMargin: + state = _terminal.LeftRightMarginMode ? 1 : 2; + break; + + default: + return; + } + _terminal.RaiseDataReceived($"\u001b[?{mode};{state}$y"); } @@ -3582,6 +3778,10 @@ private void SetCSIMode(int mode, bool isPrivate) MoveCursorToHome(); break; + case TerminalMode.LeftRightMargin: + _terminal.LeftRightMarginMode = true; + break; + case TerminalMode.Wraparound: // Mode 7: Wraparound mode // Wraparound and AutoWrapMode share value 7 in the enum @@ -3787,6 +3987,14 @@ private void ResetCSIMode(int mode, bool isPrivate) MoveCursorToHome(); break; + case TerminalMode.LeftRightMargin: + // Turning the mode off widens the margins back out, per DEC. Leaving them + // narrowed would keep the region in force with no sequence able to reach it -- + // CSI s means Save Cursor again the moment the mode is off. + _terminal.LeftRightMarginMode = false; + _buffer.ResetLeftRightMargins(); + break; + case TerminalMode.Wraparound: // Mode 7: Wraparound mode _terminal.Options.Wraparound = false; @@ -3938,8 +4146,9 @@ private void IndexDown() private void NextLine() { + // NEL is Index plus carriage return in xterm, so the column follows CR’s margin rule. IndexDown(); - _buffer.SetCursor(0, _buffer.Y); + _buffer.CarriageReturn(); } private void ReverseIndex() diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index 1ec8576..5bcee47 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -38,6 +38,12 @@ public class Terminal public bool ApplicationKeypad { get; set; } public bool BracketedPasteMode { get; set; } public bool OriginMode { get; set; } + + /// + /// DECLRMM (mode 69). While set, CSI Pl ; Pr s sets the left and right margins rather + /// than saving the cursor, and the scrolling region is a box instead of a band of rows. + /// + public bool LeftRightMarginMode { get; set; } public bool CursorVisible { get; set; } public bool ReverseWraparound { get; set; } public bool ReverseVideo { get; set; } @@ -324,6 +330,7 @@ public Terminal(TerminalOptions? options = null) ApplicationKeypad = false; BracketedPasteMode = false; OriginMode = false; + LeftRightMarginMode = false; CursorVisible = true; ReverseWraparound = false; SendFocusEvents = false; @@ -469,6 +476,7 @@ public void Reset() ApplicationKeypad = false; BracketedPasteMode = false; OriginMode = false; + LeftRightMarginMode = false; CursorVisible = true; ReverseWraparound = false; ReverseVideo = false; @@ -1116,7 +1124,7 @@ private void HandleExecute(int code) break; case 0x0D: // CR - Carriage Return - _buffer.SetCursor(0, _buffer.Y); + _buffer.CarriageReturn(); break; case 0x0E: // SO - Shift Out (select G1 charset) @@ -1145,10 +1153,11 @@ private void LineFeed() _buffer.SetCursor(_buffer.X, _buffer.Y + 1); } - // If ConvertEol is enabled, also do a carriage return (move to column 0) + // If ConvertEol is enabled, also do a carriage return — to the line’s start as CR + // defines it, which with margins is the left margin, not column 0 if (Options.ConvertEol) { - _buffer.SetCursor(0, _buffer.Y); + _buffer.CarriageReturn(); } LineFed?.Invoke(this, new TerminalEvents.LineFeedEventArgs("\n"));