From d8cda9482760cc9b0bcae0dd29b4654e55b808ed Mon Sep 17 00:00:00 2001 From: Anton Rudakov Date: Wed, 24 Jun 2026 09:59:05 +0300 Subject: [PATCH] Clear the dirty region in the CoreGraphics renderer (fixes restricted-region artifacts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrolling or editing inside a restricted DECSTBM scroll region (a fixed header/ footer, as in nano/vim/less/htop) left stale rows and a sub-cell "ghost" on the region's last row on the CoreGraphics renderer (Metal masks it by clearing the whole target each frame). The defect is in the renderer, not the scroll primitives: drawTerminalContents fills only cells that carry an explicit background — default-background cells rely on transparent backing-store pixels showing the layer's background color — and AppKit clears the backing store only on a full-view redraw. So a partial repaint (a restricted region, IL/DL) keeps stale pixels, and the sub-cell remainder below the band is only invalidated when rowEnd == rows-1. Fix in AppleTerminalView (macOS CG path only, Metal unaffected): - clear the invalidated region to transparent before the per-row paint (preserves a translucent background), and - extend a mid-screen region's invalidation down one cell so its bottom remainder is cleared too. This fixes every row-mutating primitive at the source. The scroll primitives then only need to flag the rows they changed, so refreshScrolledRegion flags just [scrollTop, scrollBottom] instead of the whole viewport, keeping the full-screen+scrollback cheap path. Verified in nano/vim/less/htop, translucency intact; the package builds cleanly. --- .../SwiftTerm/Apple/AppleTerminalView.swift | 18 +++++++++++ Sources/SwiftTerm/Terminal.swift | 30 +++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftTerm/Apple/AppleTerminalView.swift b/Sources/SwiftTerm/Apple/AppleTerminalView.swift index ed17f9a1..0ebdeee5 100644 --- a/Sources/SwiftTerm/Apple/AppleTerminalView.swift +++ b/Sources/SwiftTerm/Apple/AppleTerminalView.swift @@ -1284,6 +1284,16 @@ extension TerminalView { } var placeholderImageCache: [UInt32: TTImage] = [:] + #if os(macOS) + // Clear the invalidated region before painting. We fill only cells that carry + // an explicit background; default-background cells rely on transparent backing- + // store pixels showing the layer's background color. AppKit clears the backing + // store only on a full-view redraw, so a partial repaint (a restricted DECSTBM + // scroll region, line insert/delete) otherwise keeps stale glyphs/backgrounds. + // Clear to transparent — not fill — so a translucent background is preserved. + context.clear(dirtyRect) + #endif + for row in firstRow...lastRow { if row < 0 { continue @@ -1773,6 +1783,14 @@ extension TerminalView { let oh = region.height let oy = region.origin.y region = CGRect (x: 0, y: 0, width: frame.width, height: oh + oy) + } else { + // Region ends mid-screen (a restricted DECSTBM region): extend the + // invalidation down by one cell so the sub-cell remainder just below the + // band's bottom row (descenders / tall unicode) is cleared too. Previously + // only rowEnd == rows-1 got this, leaving a one-row ghost below the region. + let extra = cellDimension.height + let newY = max (0, region.origin.y - extra) + region = CGRect (x: 0, y: newY, width: frame.width, height: region.maxY - newY) } #if canImport(MetalKit) if metalView != nil { diff --git a/Sources/SwiftTerm/Terminal.swift b/Sources/SwiftTerm/Terminal.swift index 4e8532ea..c822d27c 100644 --- a/Sources/SwiftTerm/Terminal.swift +++ b/Sources/SwiftTerm/Terminal.swift @@ -2523,6 +2523,11 @@ open class Terminal { } // this.maxRange(); updateRange (startLine: buffer.y, endLine: buffer.scrollBottom) + // A restricted region leaves stale pixels / a bottom-edge ghost outside + // [y, scrollBottom] on the CG renderer (as in scroll()); the range above + // already covers full-screen, so widen to the whole viewport only when the + // region is restricted. + refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: true) } // @@ -4750,7 +4755,7 @@ open class Terminal { } } // this.maxRange(); - updateRange (startLine: buffer.scrollTop, endLine: buffer.scrollBottom) + refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false) } // @@ -4786,7 +4791,7 @@ open class Terminal { } } // this.maxRange(); - updateRange (startLine: buffer.scrollTop, endLine: buffer.scrollBottom) + refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false) } // @@ -4863,6 +4868,11 @@ open class Terminal { // this.maxRange(); updateRange (startLine: buffer.y, endLine: buffer.scrollBottom) + // A restricted region leaves stale pixels / a bottom-edge ghost outside + // [y, scrollBottom] on the CG renderer (as in scroll()); the range above + // already covers full-screen, so widen to the whole viewport only when the + // region is restricted. + refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: true) } // @@ -5260,6 +5270,16 @@ open class Terminal { var blankLine: BufferLine = BufferLine(cols: 0) + /// Flag the scrolled region dirty. The CoreGraphics renderer now clears any + /// dirtied region before painting (see AppleTerminalView), so flagging just + /// [top, bottom] fixes the stale rows / bottom ghost — no whole-viewport repaint + /// needed. Full-screen + scrollback (`canBlit`) keeps the cheap path. + private func refreshScrolledRegion(top: Int, bottom: Int, canBlit: Bool) { + if top != 0 || bottom != rows - 1 || !canBlit { + updateRange(startLine: top, endLine: bottom) + } + } + public func scroll (isWrapped: Bool = false) { let buffer = self.buffer @@ -5385,9 +5405,7 @@ open class Terminal { updateRange (scrollTop, scrolling: true) updateRange (scrollBottom, scrolling: true) - if !hasScrollback { - updateRange(startLine: scrollTop, endLine: scrollBottom) - } + refreshScrolledRegion(top: scrollTop, bottom: scrollBottom, canBlit: hasScrollback) if buffer.hasAnyImages { updateKittyRelativePlacementsForCurrentBuffer() @@ -5826,7 +5844,7 @@ open class Terminal { } buffer.lines [topRow] = buffer.getBlankLine (attribute: eraseAttr ()) } - updateRange (startLine: buffer.scrollTop, endLine: buffer.scrollBottom) + refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false) } } else if buffer.y > 0 { buffer.y -= 1