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"));