Implement DECSLRM, left and right margins (CSI Pl ; Pr s) - #46
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The margin-column scrolling path does not currently preserve/clear BufferLine.IsWrapped consistently, which can break wrap-chain semantics used by selection/reflow.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds DEC left/right margin support (DECLRMM/DECSLRM) to the terminal core so side-by-side/pane layouts can scroll and edit within a column-bounded region without corrupting adjacent panes.
Changes:
- Introduces DECLRMM (mode 69) and interprets
CSI … sas DECSLRM while the mode is set. - Implements margin-aware scrolling/wrapping and bounds key editing operations (IL/DL/ICH/DCH) to the active region semantics.
- Adds a focused test suite covering mode behavior, queries, wrapping/scrolling/editing interactions, and resize/reset behavior.
File summaries
| File | Description |
|---|---|
| src/XTerm.NET/Terminal.cs | Adds LeftRightMarginMode state and ensures it resets correctly. |
| src/XTerm.NET/InputHandler.cs | Routes CSI s based on DECLRMM, applies origin-mode column addressing, and bounds wrap/edit/IL/DL behavior to margins. |
| src/XTerm.NET/Common/TerminalMode.cs | Adds TerminalMode.LeftRightMargin = 69 with documentation. |
| src/XTerm.NET/Buffer/TerminalBuffer.cs | Stores left/right margins and implements margin-column scrolling path. |
| src/XTerm.NET.Tests/LeftRightMarginTests.cs | Adds coverage for DECLRMM/DECSLRM behavior and all key operations affected by margins. |
Review details
Suppressed comments (2)
src/XTerm.NET/Buffer/TerminalBuffer.cs:489
ScrollMarginColumnsmoves the margin region by copying cells between existingBufferLineinstances, but it doesn't copy the associatedIsWrappedmetadata. That means wrap-chain state can become detached from the text content after a margin scroll / IL / DL, leading to incorrect reflow/selection behavior. Copy the wrap flag alongside the cells.
var to = _lines[_yBase + toRow];
if (from is null || to is null)
return;
to.CopyCellsFrom(from, left, left, width, false);
src/XTerm.NET/Buffer/TerminalBuffer.cs:496
FillMarginColumnsblanks the margin region in-place but leaves the line'sIsWrappedflag unchanged. For scrolled-in blank areas (and for IL/DL-created blanks), the destination lines should not remain marked as wrapped continuations from whatever previously occupied that row.
private void FillMarginColumns(int row, int left, int width, BufferCell fill)
{
var line = _lines[_yBase + row];
line?.Fill(fill, left, left + width);
}
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
An application scrolling one column of a side-by-side layout scrolls the whole screen without this. It is the last of the two VT gaps the roadmap listed, and unlike REP it is not a small one: margins are not a parameter on the existing scroll, they are a second scroll. CSI s is two sequences sharing a final character. With DECLRMM (mode 69) set it is DECSLRM; without it, Save Cursor. So this changes the meaning of a sequence already handled rather than adding one, and getting it backwards would make an application's margins silently save the cursor. A HALF implementation would have been worse than none. An application sets mode 69, asks DECRQM whether it took, and changes its drawing on the answer -- so reporting the mode as supported while scrolling ignored the margins would corrupt exactly the layouts the feature exists for, quietly, and only in the applications careful enough to ask. Every operation that moves content therefore honours them: - Scrolling. TerminalBuffer.ScrollUp/Down move whole LINES through the ring, which is what feeds the scrollback and what line recycling depends on. Narrowed margins cannot use that path at all -- only part of each line moves and nothing is promoted to scrollback -- so ScrollMarginColumns is a separate implementation. The choice is made inside ScrollUp rather than at each call site, because every wrap, LF and IND arrives through it. - Autowrap, in all three print paths. - IL and DL, which shift only the margin columns, and do nothing at all from a cursor outside them: a cursor in the right-hand pane shifting the left pane's lines is the corruption this prevents. - ICH and DCH, bounded by the right margin, so nothing is pushed into the next pane and the blanks appear at the margin rather than the screen edge. - Origin mode, where the region is a box, so column 1 is the left margin. - A resize, which clamps the margins and widens them if the pair would go degenerate. - RIS and DECSTR, which widen them. Two things nearly leaked past the margin, both the same shape as bugs found earlier in this work -- a batched writer bypassing a rule the per-character path follows: - The run writers clamped `take` to Cols, so a batch wrote straight through the margin, and only when the fast path took the write. - A double-width character on the last column of a region planted its spacer in the pane next door. And one that only a test found: the cursor is allowed to rest one past the last column it wrote, which is how a pending wrap is represented. Reading that column as OUTSIDE the margins handed the wrap limit back to the screen edge at exactly the moment the wrap was due, so text ran through the margin anyway. Twenty tests. Measured against main with the harness from tomlm#42, at four hundred million characters a corpus so the noise floor is ±1%: scroll-ascii +0.5%, everything else within ±3%, allocation identical. A first run at a quarter of that work read +4.7% on scroll-ascii against ±5% noise, which is the reason for the longer one rather than a shrug. Not here, deliberately: DECIC and DECDC, insert and delete COLUMN. They are separate sequences that exist alongside margins rather than part of them, and this is already the invasive change. 1177 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot flagged that the margin-column scroll path never forwards isWrapped to the bottom line the way the full-width path does. Writing the test showed the truth is one level up: the WRAP path itself was marking lines inside the box as continuations, on all three writers, before any scroll was involved. Forwarding the flag would have been the wrong fix. IsWrapped 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 a line wrapped claims 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. So the rule is now uniform and deliberate: only a FULL-WIDTH wrap -- limit at the real right edge, home at column zero -- marks the next line as a continuation, decided before the cursor moves; and a box scroll neither sets nor clears any line's flag, documented at the ScrollUp fork. The test pins both directions at once: rows inside a scrolling box never become continuations, and a genuinely wrapped pair below the region keeps its flag through all of it. Also updates the DECRQM remarks, which still said the method answers for mode 2026 alone; it has answered for 69 as well since this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f571dd2 to
853f3ef
Compare
tomlm
left a comment
There was a problem hiding this comment.
Reviewed for misaligned doc comments and medium/high impact issues. The structure is right — margins as a second scroll rather than a parameter, the choice made inside ScrollUp, DECLRMM gating CSI s, DECRQM answering for 69, and the three leaks called out in the description are all real and correctly fixed. Six findings, three of them high.
High
InsideScrollRegion()also gates IL and DL on the no-margins path, where the cursor legitimately rests atX == Colsin the pending-wrap state. IL/DL after a full-width line silently stop working for everyone, margins or not.- ICH and DCH are bounded by the right margin but not the left, so running them from a cursor left of the left margin shifts content straight across it into the next pane — the corruption
InsideScrollRegion()was added to prevent for IL/DL. WrapLimit()/WrapHome()cannot distinguish "pending wrap at the right margin" from "cursor positioned at the column just right of the margin", because both areScrollRight + 1. A write at the first column of the next pane is wrapped back into this one.
Medium
- CUF/CUB/CNL/CPL still clamp to the screen edge, so
CSI 200 Cwalks out of the region. ScrollUp's<summary>still says it matches xterm.jsBuffer.scroll(), which is now false for half the method, and the newly-conditionalisWrappedhas no<param>.ScrollMarginColumnsis public and takestop/bottomas parameters but reads its columns from private margin state, which the doc does not mention.
Notes (checked, no action)
- The BCE attributes line up: full-width IL/DL use
GetBlankLine(_curAttr)and the margin path usesBlankCell()(also_curAttr); full-widthScrollUprecycles withAttributeData.Defaultand the margin path usesBlankFill()(also default). Same sequence, same fill, either path — worth stating in the description since it is the kind of thing that usually diverges. ScrollMarginColumnscopies in the direction of travel on both arms; a region taller than the distance moved does not clobber itself. The<remarks>claim holds.- The
Resizemargin clamp survives the degenerate cases:newCols == 1collapses toleft == right == 0, whichMarginsAreFullWidththen reads as full width rather than leaving a region nothing can write to. - The XML docs on the new members in
TerminalBuffer.csare all attached to the right members — no orphaned<remarks>or duplicated<summary>anywhere in the diff. The two doc problems below are content drift, not misplacement. - "either leaving margins in force across a full reset would hand the next application..." in
ResetScrollRegionis missing a clause — a strayeitherfrom an edit.
All three highs traced to one root: the cursor position X == ScrollRight + 1 was carrying two meanings. It is both the pending-wrap residue of filling the region's last column (inside the region -- 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 DECSLRM exists for). Every helper that read the position had to pick one meaning, and whichever it picked broke the other case somewhere. THE FLAG. TerminalBuffer.PendingWrap maps xterm's wrap flag onto the seam the code already had: SetCursorRaw is how PRINTING advances the cursor and sets it; SetCursor is how everything else moves the cursor and clears it, matching xterm's rule that explicit movement cancels a pending wrap. The one-past representation stays -- the whole codebase assumes it -- but the boundary column is no longer ambiguous. ONE PREDICATE. CursorInMarginColumns() is the single in/out answer, so no two helpers can disagree about the same column again. WrapLimit and WrapHome collapse onto it; InsideScrollRegion routes its column test through it, which fixes IL/DL going dead after any full-width line (the pending X == Cols read as "outside" even with no margins set -- a regression on the default path); and a deliberately-placed cursor at the next pane's first column now writes there instead of wrapping back into the pane to its left, unmarked row-move and all. ICH and DCH are bounded by BOTH margins: a cursor left of the left margin now does nothing, where it previously shifted the left pane's columns across the margin (ICH) or pulled the right pane's back across (DCH) -- the corruption InsideScrollRegion was added to prevent, half-applied. Both sides of both sequences are tested. CUF stops at the right margin and CUB at the left whenever the cursor starts inside the region, screen edge otherwise -- in/out decides, not origin mode, as in xterm -- so CSI 200 C no longer walks out of the pane. CNL/CPL home to the left margin under origin mode, the same resolution MoveCursorToHome uses. Docs: ScrollUp's summary no longer claims xterm.js Buffer.scroll() behaviour for the box path xterm.js does not have, and isWrapped's conditionality is on the signature instead of buried in the body; ScrollDown says it dispatches the same way; ScrollMarginColumns is internal now -- its row parameters are an implementation detail of its four callers, not an API -- and its doc says the columns always come from the current margins. The stray "either" in ResetScrollRegion's comment is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CR now 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 (CarriageReturn in charproc.c), origin mode not consulted. A CRLF from an application drawing inside its pane starts the next line at the pane's edge instead of walking the cursor into the pane next door, which was the last way ordinary output could still escape the region. One method carries the rule: TerminalBuffer.CarriageReturn(), and every operation that "returns the carriage" routes through it -- CR itself, NEL (Index plus CR in xterm), CNL/CPL (CUD/CUU plus CR in xterm, replacing the origin-mode column they briefly had here), and a line feed under ConvertEol -- so none of them can disagree about where a line starts. Tests cover CR from both sides of the margin, the NEL/CNL/CPL trio under the same rule, and the ConvertEol path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An application scrolling one column of a side-by-side layout scrolls the whole screen without this. It is the second of the two VT gaps on the roadmap — and unlike REP in #44 (now merged), it is not a small one. Rebased onto that merge and conflict-free; Copilot's two findings are fixed and their threads resolved. Margins are not a parameter on the existing scroll; they are a second scroll.
CSI sis two sequencesWith DECLRMM (mode 69) set it is DECSLRM; without it, Save Cursor. So this changes the meaning of a sequence already handled rather than adding one, and getting it backwards would make an application's margins silently save the cursor.
Why a half implementation would be worse than none
An application sets mode 69, asks DECRQM whether it took, and changes its drawing on the answer. Reporting the mode as supported while scrolling ignored the margins would corrupt exactly the layouts the feature exists for — quietly, and only in the applications careful enough to ask.
So every operation that moves content honours them:
ScrollUp/ScrollDownmove whole lines through the ring, which is what feeds the scrollback and what line recycling depends on. Narrowed margins cannot use that path at all — only part of each line moves, and nothing is promoted to scrollback — soScrollMarginColumnsis a separate implementation. The choice is made insideScrollUp, because every wrap, LF and IND arrives through it and finding all of them once means not finding them again next time.Three things nearly leaked past the margin
Two are the same shape as bugs found earlier in this work — a batched writer bypassing a rule the per-character path follows:
taketoCols, so a batch wrote straight through the margin — and only when the fast path took the write.The third only a test found. The cursor is allowed to rest one past the last column it wrote — that is how a pending wrap is represented. Reading that column as outside the margins handed the wrap limit back to the screen edge at exactly the moment the wrap was due, so the text ran through the margin anyway. It looked right in every code path and was wrong in the one state that matters.
CR returns to the left margin
CR follows xterm's rule (
CarriageReturnin charproc.c): the left margin when the cursor is at or right of it, column 0 only when the cursor is left of it, origin mode not consulted. One method carries the rule and everything that "returns the carriage" routes through it — CR, NEL, CNL/CPL, and line feed under ConvertEol — so a CRLF inside a pane starts the next line at the pane's edge rather than in the pane next door.BCE agrees on both paths
The fills line up whichever path a sequence takes: full-width IL/DL blank with
GetBlankLine(_curAttr)and the box path withBlankCell()(also_curAttr); full-width scrolling recycles withAttributeData.Defaultand the box path fills withBlankFill()(also default). Same sequence, same fill, either path — stated here because it is the kind of pairing that usually diverges silently.Cost
Measured against
mainwith the harness from #42, at 400M characters per corpus so the noise floor is ±1%:Allocation byte-identical throughout.
A first pass at a quarter of that work read +4.7% on
scroll-asciiagainst ±5% noise — which is precisely the band the CI gate marks "worth a look" rather than failing, and precisely why it does. Four times the work and it was +0.5%.Not here, deliberately
DECIC and DECDC — insert and delete column. They are separate sequences that exist alongside margins rather than part of them, and this is already the invasive change. Happy to follow up.
Also unchanged: a double-width character at the screen edge still drops its spacer rather than wrapping to the next line. That is pre-existing and orthogonal — I bounded the spacer by the margin without touching what happens at the edge.
Tests
1177 pass, twenty of them new — the mode, the sequence, the query, and one per operation above. The batched-path clamp was verified by removing it and watching three tests fail.
🤖 Generated with Claude Code