@@ -27,10 +27,32 @@ public final class BufferLine: CustomDebugStringConvertible {
2727 /// BiDi state for the paragraph that contains this row.
2828 public internal( set) var bidiState : BidiPresentationState { didSet { bump ( ) } }
2929 var renderMode : RenderLineMode = . single { didSet { bump ( ) } }
30- /// Semantic prompt classification for this row. Cell-level roles live in
31- /// `CharData.semanticContent`; this distinguishes primary and continuation
32- /// prompt rows even when they are currently blank.
33- public private( set) var semanticPromptKind : SemanticPromptKind ? { didSet { bump ( ) } }
30+ /// Shell-authored OSC 133 marks on this line, at most one per kind.
31+ /// A line can carry both a left prompt and a right prompt mark.
32+ private( set) var semanticMarks : [ SemanticMark ] = [ ] { didSet { bump ( ) } }
33+ /// The prompt-group epoch of a line created by a hard line feed inside a
34+ /// prompt group: the active group ID at stamp time, or nil for a line that
35+ /// is not a hard continuation. Together with `isWrapped` this is what makes
36+ /// a row derivable as a continuation, and the group ID is what keeps an old
37+ /// group's stranded rows from joining a new prompt (R1/R5). Exactly one
38+ /// function assigns it: `Terminal.finishSemanticLineAdvance`.
39+ ///
40+ /// It is structural, not content: cell erasure (EL/ED) never touches it.
41+ /// Derivation-only metadata, not drawn, so it bumps the render generation
42+ /// only on an actual change.
43+ var semanticHardContinuationGroup : UInt64 ? = nil {
44+ didSet { if semanticHardContinuationGroup != oldValue { bump ( ) } }
45+ }
46+ /// Weak link to the owning buffer, used only so `copyFrom` can ask which
47+ /// of two colliding same-kind marks is the live origin. Lines that never
48+ /// carry marks (bare templates) leave this nil.
49+ weak var owningBuffer : Buffer ?
50+ /// Bumped each time this line object is reused for different content
51+ /// (recycle, reset). A deferred pointer click captures this alongside the
52+ /// line identity; a mismatch at fire time means the object was recycled
53+ /// into a new row and the click must be dropped — identity alone cannot
54+ /// tell, because `CircularList.recycle` keeps the object in the array.
55+ private( set) var recycleGeneration : UInt64 = 0
3456 private var data : UnsafeMutableBufferPointer < CharData >
3557 private var dataSize : Int
3658
@@ -56,7 +78,6 @@ public final class BufferLine: CustomDebugStringConvertible {
5678 data = buf
5779 dataSize = cols
5880 self . isWrapped = isWrapped
59- semanticPromptKind = nil
6081 self . bidiState = bidiState
6182 }
6283
@@ -66,7 +87,11 @@ public final class BufferLine: CustomDebugStringConvertible {
6687 isWrapped = other. isWrapped
6788 bidiState = other. bidiState
6889 renderMode = other. renderMode
69- semanticPromptKind = other. semanticPromptKind
90+ semanticMarks = other. semanticMarks
91+ semanticHardContinuationGroup = other. semanticHardContinuationGroup
92+ // owningBuffer is NOT inherited: a clone is stamped with its real
93+ // owner when it is attached to a buffer (B.3). Inheriting it from a
94+ // cross-buffer template leaks the wrong owner.
7095 images = other. images
7196 let otherSize = other. dataSize
7297 let buf = UnsafeMutableBufferPointer< CharData> . allocate( capacity: otherSize)
@@ -128,13 +153,22 @@ public final class BufferLine: CustomDebugStringConvertible {
128153 return Int ( data [ index] . width)
129154 }
130155
156+ /// Erases the cell contents. Marks are line-level metadata and survive
157+ /// this: destruction of a recycled line goes through `destroySemanticState`.
131158 func clear( with attribute: Attribute ) {
132159 let empty = CharData ( attribute: attribute)
133160 data. update ( repeating: empty)
134- semanticPromptKind = nil
135161 images = nil
162+ recycleGeneration &+= 1
136163 bump ( )
137164 }
165+
166+ /// Removes the semantic prompt metadata. Called only when the line
167+ /// itself is destroyed (recycled for reuse), never by cell mutations.
168+ func destroySemanticState( ) {
169+ semanticMarks. removeAll ( keepingCapacity: true )
170+ semanticHardContinuationGroup = nil
171+ }
138172 /// Test whether contains any chars.
139173 public func hasContent ( index: Int ) -> Bool {
140174 data [ index] . code != 0 || data [ index] . attribute != CharData . defaultAttr;
@@ -171,6 +205,7 @@ public final class BufferLine: CustomDebugStringConvertible {
171205 data [ i] = fillData
172206 }
173207 }
208+ marksShift ( from: pos, by: n, rightMargin: rightMargin)
174209 bump ( )
175210 }
176211
@@ -191,10 +226,51 @@ public final class BufferLine: CustomDebugStringConvertible {
191226 data [ i] = fillData
192227 }
193228 }
229+ marksShift ( from: pos, by: - n, rightMargin: rightMargin)
194230 bump ( )
195231 }
196232
197- /// Replaces the cells in the start to end range with the specified fill data
233+ /// Shifts mark columns with their cells when ICH inserts or DCH deletes
234+ /// cells at `pos` inside the margin. Marks are never removed here: a mark
235+ /// whose cell was deleted lands on `pos`, one pushed past the margin
236+ /// stays on the margin.
237+ func marksShift( from pos: Int , by delta: Int , rightMargin: Int ) {
238+ guard delta != 0 , !semanticMarks. isEmpty else { return }
239+ semanticMarks = semanticMarks. map { mark in
240+ guard mark. column >= pos, mark. column <= rightMargin else { return mark }
241+ var result = mark
242+ result. column = min ( max ( mark. column + delta, pos) , rightMargin)
243+ return result
244+ }
245+ }
246+
247+ /// Clamps every mark column into the line's content width, for shrinking
248+ /// resizes. A zero-width line can hold no marks.
249+ func marksClampTo( width: Int ) {
250+ guard !semanticMarks. isEmpty else { return }
251+ if width <= 0 {
252+ semanticMarks. removeAll ( keepingCapacity: true )
253+ return
254+ }
255+ semanticMarks = semanticMarks. map { mark in
256+ var result = mark
257+ result. column = min ( max ( mark. column, 0 ) , width - 1 )
258+ return result
259+ }
260+ }
261+
262+ /// Stores a shell-authored mark. Setting a mark of a kind the line
263+ /// already carries replaces that mark: readline redisplay re-emits `A`
264+ /// on the same row on every repaint.
265+ func setSemanticMark( kind: SemanticPromptKind , column: Int , group: UInt64 ) {
266+ semanticMarks. removeAll { $0. kind == kind }
267+ semanticMarks. append ( SemanticMark ( kind: kind, column: column, group: group) )
268+ semanticMarks. sort { $0. column < $1. column }
269+ }
270+
271+ /// Replaces the cells in the start to end range with the specified fill data.
272+ /// Cell erasure never touches the continuation epoch (R1: structural, not
273+ /// content).
198274 public func replaceCells ( start: Int , end: Int , fillData : CharData )
199275 {
200276 let length = dataSize
@@ -259,6 +335,7 @@ public final class BufferLine: CustomDebugStringConvertible {
259335 data = UnsafeMutableBufferPointer< CharData> . allocate( capacity: 0 )
260336 dataSize = 0
261337 }
338+ marksClampTo ( width: cols)
262339 }
263340 }
264341
@@ -305,15 +382,12 @@ public final class BufferLine: CustomDebugStringConvertible {
305382 }
306383 dataSize = srcSize
307384 isWrapped = line. isWrapped
308- semanticPromptKind = line. semanticPromptKind
385+ semanticMarks = line. semanticMarks
386+ semanticHardContinuationGroup = line. semanticHardContinuationGroup
309387 bidiState = line. bidiState
310388 bump ( )
311389 }
312390
313- func setSemanticPromptKind( _ kind: SemanticPromptKind ? ) {
314- semanticPromptKind = kind
315- }
316-
317391 /// Returns the trimmed length in terms of cells used from the BufferLine
318392 ///
319393 public func getTrimmedLength ( ) -> Int
@@ -334,6 +408,8 @@ public final class BufferLine: CustomDebugStringConvertible {
334408 /// - len: the number of elements to copy
335409 public func copyFrom ( _ src: BufferLine , srcCol: Int , dstCol: Int , len: Int )
336410 {
411+ let movesSemanticHardContinuation = srcCol == 0 && dstCol == 0 && len >= count
412+ let movedSemanticHardContinuationGroup = src. semanticHardContinuationGroup
337413 if src === self && srcCol > dstCol {
338414 // Overlapping forward copy: go left-to-right (already safe)
339415 for i in 0 ..< len {
@@ -349,6 +425,71 @@ public final class BufferLine: CustomDebugStringConvertible {
349425 data [ dstCol + i] = src. data [ srcCol + i]
350426 }
351427 }
428+ // Marks travel with their cells. The margin-scroll paths and reflow
429+ // shuffle cell ranges between line objects through this call, and a
430+ // mark's row is wherever its cells went: this moves marks, it never
431+ // authors them. Marks outside the copied range stay where they are.
432+ let moved = src. semanticMarks. filter { $0. column >= srcCol && $0. column < srcCol + len }
433+ // Snapshot the live origin before the marks move, so a same-kind
434+ // collision can be resolved by liveness rather than position. Skipped
435+ // entirely on the common no-marks scroll row (E.5).
436+ let origin = moved. isEmpty ? nil : owningBuffer? . rawSemanticOrigin ( )
437+ if src === self {
438+ semanticMarks. removeAll {
439+ ( $0. column >= srcCol && $0. column < srcCol + len) ||
440+ ( $0. column >= dstCol && $0. column < dstCol + len)
441+ }
442+ } else {
443+ src. semanticMarks. removeAll { $0. column >= srcCol && $0. column < srcCol + len }
444+ semanticMarks. removeAll { $0. column >= dstCol && $0. column < dstCol + len }
445+ }
446+ if !moved. isEmpty {
447+ var originMovedHere = false
448+ for mark in moved {
449+ let newColumn = mark. column - srcCol + dstCol
450+ let movedIsOrigin = origin. map {
451+ $0. line === src && $0. kind == mark. kind && $0. column == mark. column
452+ } ?? false
453+ if let idx = semanticMarks. firstIndex ( where: { $0. kind == mark. kind } ) {
454+ // A same-kind mark outside the copied destination range did
455+ // not move. The live origin always wins the collision: if
456+ // the origin is the mark that moved here, it displaces the
457+ // stationary one; otherwise the stationary mark is kept
458+ // (it is the origin, or — absent origin info — the
459+ // conservative choice) and the moved duplicate is dropped.
460+ let stationaryIsOrigin = origin. map {
461+ $0. line === self && $0. kind == mark. kind && $0. column == semanticMarks [ idx] . column
462+ } ?? false
463+ if movedIsOrigin && !stationaryIsOrigin {
464+ semanticMarks. remove ( at: idx)
465+ semanticMarks. append ( SemanticMark ( kind: mark. kind, column: newColumn,
466+ group: mark. group) )
467+ originMovedHere = true
468+ }
469+ } else {
470+ semanticMarks. append ( SemanticMark ( kind: mark. kind, column: newColumn,
471+ group: mark. group) )
472+ if movedIsOrigin {
473+ originMovedHere = true
474+ }
475+ }
476+ }
477+ semanticMarks. sort { $0. column < $1. column }
478+ if originMovedHere, src !== self {
479+ // The origin's cells now live on this line; follow them so
480+ // the getter binds to this object instead of relying on the
481+ // rebind heuristic.
482+ owningBuffer? . reassignSemanticOrigin ( to: self )
483+ }
484+ }
485+ // A full-width copy moves the continuation epoch with the content; a
486+ // narrow-margin copy leaves it (R1: dead clicks over injection).
487+ if movesSemanticHardContinuation {
488+ semanticHardContinuationGroup = movedSemanticHardContinuationGroup
489+ if src !== self {
490+ src. semanticHardContinuationGroup = nil
491+ }
492+ }
352493 bump ( )
353494 }
354495
0 commit comments