Skip to content

Commit e4f31b0

Browse files
committed
Add backgroundOpacity for translucent terminal backgrounds
backgroundOpacity (0...1) on both platform views renders the default background translucently, Terminal.app style: text, caret, selection and cells with explicit backgrounds stay opaque. On macOS the opacity rides in the alpha of nativeBackgroundColor (assigning an alpha- bearing color is equivalent); on iOS it rides in the layer background that already paints the terminal background there. Fixing the pixel-verified double compositing this exposed: - the CG path no longer fills runs that carry the default background (the layer background already paints it, including margins) - the Metal path no longer emits quads for default-background runs (the pass clear color paints it), and the host layer background is cleared while Metal renders so the two never stack - CAMetalLayer is made non-opaque when translucent Verified by pixel sampling over a known backdrop: CG and Metal now produce identical blends at 50% opacity, and opaque rendering is byte-identical to before.
1 parent 6319a91 commit e4f31b0

5 files changed

Lines changed: 98 additions & 30 deletions

File tree

Sources/SwiftTerm/Apple/AppleTerminalView.swift

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,7 +1745,11 @@ extension TerminalView {
17451745
}
17461746
processedGlyphs += runGlyphsCount
17471747

1748-
if let backgroundColor = preparedRun.backgroundColor {
1748+
// Runs carrying the default background are not filled: the
1749+
// view's layer background already paints that color, and
1750+
// filling it again would double-composite when the
1751+
// background is translucent (backgroundOpacity < 1)
1752+
if let backgroundColor = preparedRun.backgroundColor, backgroundColor != nativeBackgroundColor {
17491753
let columnSpan = max(0, endColumn - startColumn)
17501754
if columnSpan > 0 {
17511755
var rect = CGRect(
@@ -1762,18 +1766,8 @@ extension TerminalView {
17621766
}
17631767
#endif
17641768

1765-
if endColumn >= terminal.cols {
1766-
if backgroundColor == nativeBackgroundColor {
1767-
rect.size.width = frame.width - rect.origin.x
1768-
} else {
1769-
let marginX = rect.origin.x + rect.size.width
1770-
if marginX < frame.width {
1771-
let marginRect = CGRect(x: marginX, y: rect.origin.y, width: frame.width - marginX, height: rect.size.height)
1772-
context.setFillColor(cachedCGColor(nativeBackgroundColor))
1773-
context.fill(marginRect)
1774-
}
1775-
}
1776-
}
1769+
// The right margin beyond the last column needs no
1770+
// fill: the layer background paints it
17771771

17781772
context.setFillColor(cachedCGColor(backgroundColor))
17791773
context.fill(rect)

Sources/SwiftTerm/Apple/Metal/MetalTerminalRenderer.swift

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,27 +1144,16 @@ final class MetalTerminalRenderer: NSObject, MTKViewDelegate {
11441144
} else if runAttributes.keys.contains(.backgroundColor) {
11451145
backgroundColor = runAttributes[.backgroundColor] as? TTColor
11461146
}
1147-
if let backgroundColor = backgroundColor {
1147+
// Runs carrying the default background emit no quad: the
1148+
// pass's clear color already paints it (including the
1149+
// margins), and a quad on top would double-composite when
1150+
// the background is translucent (backgroundOpacity < 1)
1151+
if let backgroundColor = backgroundColor, backgroundColor != terminalView.nativeBackgroundColor {
11481152
let columnSpan = max(0, endColumn - startColumn)
11491153
if columnSpan > 0 {
11501154
let x0 = lineOriginPx.x + (CGFloat(startColumn) * cellWidthPx)
11511155
let y0 = lineOriginPx.y
1152-
var x1 = lineOriginPx.x + (CGFloat(startColumn + columnSpan) * cellWidthPx)
1153-
if endColumn >= buffer.cols {
1154-
if backgroundColor == terminalView.nativeBackgroundColor {
1155-
x1 = lineOriginPx.x + viewWidthPx
1156-
} else {
1157-
let marginX0 = x1
1158-
let marginX1 = lineOriginPx.x + viewWidthPx
1159-
if marginX1 > marginX0 {
1160-
let (mx0, my0, mx1, my1) = transformRect(x0: marginX0, y0: y0, x1: marginX1, y1: lineOriginPx.y + cellHeightPx)
1161-
if let mClipped = self.clipRect(mx0, my0, mx1, my1, clipRect) {
1162-
let defaultBg = colorToSIMD(terminalView.nativeBackgroundColor)
1163-
backgroundCells.append(makeColorCell(x0: mClipped.0, y0: mClipped.1, x1: mClipped.2, y1: mClipped.3, color: defaultBg))
1164-
}
1165-
}
1166-
}
1167-
}
1156+
let x1 = lineOriginPx.x + (CGFloat(startColumn + columnSpan) * cellWidthPx)
11681157
let y1 = lineOriginPx.y + cellHeightPx
11691158
let (tx0, ty0, tx1, ty1) = transformRect(x0: x0, y0: y0, x1: x1, y1: y1)
11701159
if let clipped = self.clipRect(tx0, ty0, tx1, ty1, clipRect) {

Sources/SwiftTerm/Mac/MacTerminalView.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,9 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
428428
metalView = mtkView
429429
metalRenderer = renderer
430430
metalBoundWindow = window
431+
// Metal's clear color paints the background; if the host layer
432+
// painted it too, a translucent background would composite twice
433+
layer?.backgroundColor = NSColor.clear.cgColor
431434
needsDisplay = false
432435
mtkView.setNeedsDisplay(mtkView.bounds)
433436
} else {
@@ -436,6 +439,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
436439
metalView = nil
437440
metalRenderer = nil
438441
metalBoundWindow = nil
442+
layer?.backgroundColor = nativeBackgroundColor.cgColor
439443
if let caretView = caretView {
440444
caretView.isHidden = false
441445
caretView.updateCursorStyle()
@@ -470,6 +474,8 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
470474
// so this is the colorspace they actually live in.
471475
if let metalLayer = mtkView.layer as? CAMetalLayer {
472476
metalLayer.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
477+
// Composite through the layer when the background is translucent
478+
metalLayer.isOpaque = backgroundOpacity >= 1.0
473479
}
474480
return mtkView
475481
}
@@ -716,10 +722,41 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
716722
settingBg = true
717723
_nativeBg = newValue
718724
terminal.backgroundColor = nativeBackgroundColor.getTerminalColor ()
725+
// Keep the layer background (which paints the margins) in sync,
726+
// including any translucency carried in the alpha channel; when
727+
// Metal renders, its clear color owns the background instead
728+
layer?.backgroundColor = metalView == nil ? newValue.cgColor : NSColor.clear.cgColor
719729
settingBg = false
720730
}
721731
}
722732

733+
/**
734+
* Opacity of the terminal's default background, in the 0...1 range (values are clamped).
735+
*
736+
* Values below 1 render the default background translucently, in the style of
737+
* Terminal.app's background opacity: only the default background is affected;
738+
* text, the caret, selections and cells with explicit background colors stay
739+
* fully opaque. The opacity is carried in the alpha channel of
740+
* `nativeBackgroundColor`, so assigning that property with an alpha-bearing
741+
* color is equivalent.
742+
*
743+
* For the translucency to be visible, the hosting window must be configured
744+
* to composite it: `window.isOpaque = false` and a clear
745+
* `window.backgroundColor`.
746+
*/
747+
public var backgroundOpacity: CGFloat {
748+
get {
749+
return _nativeBg.cgColor.alpha
750+
}
751+
set {
752+
let clamped = max (0.0, min (1.0, newValue))
753+
nativeBackgroundColor = _nativeBg.withAlphaComponent (clamped)
754+
// CAMetalLayer defaults to opaque; it must composite when translucent
755+
metalView?.layer?.isOpaque = clamped >= 1.0
756+
colorsChanged ()
757+
}
758+
}
759+
723760
/// Controls weather to use high ansi colors, if false terminal will use bold text instead of high ansi colors
724761
public var useBrightColors: Bool = true
725762

Sources/SwiftTerm/iOS/iOSTerminalView.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,8 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi
423423
// displays.
424424
if let metalLayer = mtkView.layer as? CAMetalLayer {
425425
metalLayer.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
426+
// Composite through the layer when the background is translucent
427+
metalLayer.isOpaque = backgroundOpacity >= 1.0
426428
}
427429
let renderer = try MetalTerminalRenderer(view: mtkView, terminalView: self)
428430
mtkView.delegate = renderer
@@ -2937,6 +2939,26 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi
29372939
caretView?.style = newStyle
29382940
updateCaretView()
29392941
}
2942+
/**
2943+
* Opacity of the terminal's default background, in the 0...1 range (values are clamped).
2944+
*
2945+
* On iOS the default background is painted by the view's layer, so the
2946+
* opacity is carried in the alpha of `layer.backgroundColor`; the view
2947+
* behind the terminal shows through when the value is below 1.
2948+
*/
2949+
public var backgroundOpacity: CGFloat {
2950+
get {
2951+
return layer.backgroundColor?.alpha ?? 1.0
2952+
}
2953+
set {
2954+
let clamped = max (0.0, min (1.0, newValue))
2955+
if let background = layer.backgroundColor {
2956+
layer.backgroundColor = background.copy (alpha: clamped)
2957+
}
2958+
colorsChanged ()
2959+
}
2960+
}
2961+
29402962
/// Controls how this view responds to the bell character; `.sound`
29412963
/// preserves the historical behavior of invoking the delegate's `bell`
29422964
public var bellStyle: BellStyle = .sound

Tests/SwiftTermTests/ProfileSupportTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,32 @@ final class ClearScrollbackTests {
187187
}
188188
}
189189

190+
@MainActor
191+
final class BackgroundOpacityTests {
192+
@Test func opacityIsClampedAndCarriedInAlpha () {
193+
let view = TerminalView (frame: CGRect (x: 0, y: 0, width: 400, height: 300))
194+
#expect (view.backgroundOpacity == 1.0)
195+
196+
view.nativeBackgroundColor = NSColor (srgbRed: 0.1, green: 0.2, blue: 0.3, alpha: 1)
197+
view.backgroundOpacity = 0.5
198+
#expect (abs (view.backgroundOpacity - 0.5) < 0.001)
199+
#expect (abs (view.nativeBackgroundColor.alphaComponent - 0.5) < 0.001)
200+
// The base color is preserved
201+
#expect (abs (view.nativeBackgroundColor.redComponent - 0.1) < 0.01)
202+
203+
view.backgroundOpacity = 3.0
204+
#expect (view.backgroundOpacity == 1.0)
205+
view.backgroundOpacity = -1.0
206+
#expect (view.backgroundOpacity == 0.0)
207+
}
208+
209+
@Test func alphaBearingBackgroundReadsAsOpacity () {
210+
let view = TerminalView (frame: CGRect (x: 0, y: 0, width: 400, height: 300))
211+
view.nativeBackgroundColor = NSColor (srgbRed: 0, green: 0, blue: 0, alpha: 0.85)
212+
#expect (abs (view.backgroundOpacity - 0.85) < 0.001)
213+
}
214+
}
215+
190216
@MainActor
191217
final class TerminalViewOptionsTests {
192218
@Test func startupOptionsReachTheTerminal () {

0 commit comments

Comments
 (0)