diff --git a/apple/GhostteaApp/App/GhostteaAppModel.swift b/apple/GhostteaApp/App/GhostteaAppModel.swift
index a9d50b49..3ea77a51 100644
--- a/apple/GhostteaApp/App/GhostteaAppModel.swift
+++ b/apple/GhostteaApp/App/GhostteaAppModel.swift
@@ -965,6 +965,8 @@ extension GhostteaTerminalPresentationConfig {
&& palette == other.palette
&& backgroundOpacity == other.backgroundOpacity
&& backgroundOpacityCells == other.backgroundOpacityCells
+ && minimumContrast == other.minimumContrast
+ && lightAdaptation == other.lightAdaptation
&& fontSize == other.fontSize
&& fontFamilies == other.fontFamilies
&& paddingX == other.paddingX
diff --git a/apple/GhostteaApp/App/GhostteaSSHAppModel.swift b/apple/GhostteaApp/App/GhostteaSSHAppModel.swift
index 4ee5f833..e362a240 100644
--- a/apple/GhostteaApp/App/GhostteaSSHAppModel.swift
+++ b/apple/GhostteaApp/App/GhostteaSSHAppModel.swift
@@ -1408,6 +1408,8 @@ extension GhostteaTerminalPresentationConfig {
palette: palette,
backgroundOpacity: backgroundOpacity,
backgroundOpacityCells: backgroundOpacityCells,
+ minimumContrast: minimumContrast,
+ lightAdaptation: lightAdaptation,
fontSize: runtime.fontSize,
fontFamilies: runtime.fontFamilies,
paddingX: paddingX,
diff --git a/apple/GhostteaApp/App/GhostteaSettingsView.swift b/apple/GhostteaApp/App/GhostteaSettingsView.swift
index 8b4dd720..27f190dc 100644
--- a/apple/GhostteaApp/App/GhostteaSettingsView.swift
+++ b/apple/GhostteaApp/App/GhostteaSettingsView.swift
@@ -135,6 +135,29 @@ private struct GhostteaAppearanceSettingsView: View {
.foregroundStyle(.secondary)
}
+ Section("Readability") {
+ Toggle(
+ "Restyle dark apps under light themes",
+ isOn: Binding(
+ get: { configuration.appearance.lightAdaptation == .auto },
+ set: { value in update { $0.lightAdaptation = value ? .auto : .off } }))
+ Picker(
+ "Minimum text contrast",
+ selection: Binding(
+ get: { configuration.appearance.minimumContrast },
+ set: { value in update { $0.minimumContrast = value } })
+ ) {
+ ForEach(contrastChoices, id: \.self) { ratio in
+ Text(contrastLabel(ratio)).tag(ratio)
+ }
+ }
+ Text(
+ "Apps that paint their own dark background are redrawn as a light design when a light theme is active. Text below the minimum contrast against its background is lightened or darkened, keeping its hue; 4.5:1 is the WCAG AA level for body text."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
if !configuration.appearance.shaderEffects.isEmpty {
Section("Shader order") {
ForEach(configuration.appearance.shaderEffects, id: \.self) { id in
@@ -201,6 +224,17 @@ private struct GhostteaAppearanceSettingsView: View {
configuration.applyAppearance(selection)
}
+ /// The offered ratios, plus a hand-edited one so the picker can show it.
+ private var contrastChoices: [Double] {
+ let current = configuration.appearance.minimumContrast
+ let choices = GhostteaAppearanceSelection.minimumContrastChoices
+ return choices.contains(current) ? choices : (choices + [current]).sorted()
+ }
+
+ private func contrastLabel(_ ratio: Double) -> String {
+ ratio > 1 ? "\(ratio.formatted(.number.precision(.fractionLength(0...2)))):1" : "Off"
+ }
+
private func shaderName(_ id: String) -> String {
GhostteaShaderOption.available.first { $0.id == id }?.name ?? id
}
diff --git a/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift b/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift
index a49ae55c..aa885556 100644
--- a/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift
+++ b/apple/GhostteaKit/Sources/GhostteaAppearance/GhostteaAppearance.swift
@@ -88,19 +88,30 @@ public struct GhostteaAppearanceSelection: Equatable, Sendable {
public var backgroundOpacityCells: Bool
public var shaderEffects: [String]
public var shaderAnimation: Bool
+ /// `ghosttea-light-adaptation`: restyle dark-painted apps under light themes.
+ public var lightAdaptation: GhostteaLightAdaptation
+ /// Ghostty `minimum-contrast` ratio (1 is off).
+ public var minimumContrast: Double
+
+ /// Ratios offered by Settings; 4.5:1 is the WCAG AA level for body text.
+ public static let minimumContrastChoices: [Double] = [1, 3, 4.5, 7]
public init(
theme: GhostteaColorTheme? = nil,
backgroundOpacity: Double = 1,
backgroundOpacityCells: Bool = false,
shaderEffects: [String] = [],
- shaderAnimation: Bool = false
+ shaderAnimation: Bool = false,
+ lightAdaptation: GhostteaLightAdaptation = .auto,
+ minimumContrast: Double = 1
) {
self.theme = theme
self.backgroundOpacity = backgroundOpacity
self.backgroundOpacityCells = backgroundOpacityCells
self.shaderEffects = shaderEffects
self.shaderAnimation = shaderAnimation
+ self.lightAdaptation = lightAdaptation
+ self.minimumContrast = minimumContrast
}
public init(config: GhostteaConfigSnapshot) {
@@ -112,6 +123,8 @@ public struct GhostteaAppearanceSelection: Equatable, Sendable {
? [GhostteaShaderEffect.betterCRT.rawValue]
: config.renderer.shaderEffects
shaderAnimation = config.renderer.customShaderAnimation
+ lightAdaptation = config.renderer.lightAdaptation
+ minimumContrast = Double(config.renderer.minimumContrast)
}
}
@@ -237,6 +250,8 @@ public enum GhostteaConfigurationDraft {
public static func appearanceBlock(_ selection: GhostteaAppearanceSelection) throws -> String {
guard selection.backgroundOpacity.isFinite,
(0...1).contains(selection.backgroundOpacity),
+ selection.minimumContrast.isFinite,
+ (1...21).contains(selection.minimumContrast),
selection.shaderEffects.allSatisfy({ id in
GhostteaShaderOption.available.contains { $0.id == id }
})
@@ -268,6 +283,8 @@ public enum GhostteaConfigurationDraft {
}
lines += [
"custom-shader-animation = \(selection.shaderAnimation)",
+ "ghosttea-light-adaptation = \(selection.lightAdaptation.rawValue)",
+ "minimum-contrast = \(contrastRatio(selection.minimumContrast))",
appearanceBlockEnd,
]
return lines.joined(separator: "\n")
@@ -434,6 +451,7 @@ public enum GhostteaConfigurationDraft {
lines.append("custom-shader =")
lines += config.renderer.shaderEffects.map { "custom-shader = \($0)" }
lines.append("custom-shader-animation = \(config.renderer.customShaderAnimation)")
+ lines.append("minimum-contrast = \(contrastRatio(Double(config.renderer.minimumContrast)))")
lines += config.renderer.palette.map { entry in
"palette = \(entry.index)=\(GhostteaFriendlyConfigValues.hex(entry.color))"
}
@@ -514,6 +532,15 @@ public enum GhostteaConfigurationDraft {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), value)
}
+ /// Up to two decimals without trailing zeros, matching the desktop's
+ /// `minimum-contrast = 4.5` rather than `4.50`.
+ private static func contrastRatio(_ value: Double) -> String {
+ var text = String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), value)
+ while text.hasSuffix("0") { text.removeLast() }
+ if text.hasSuffix(".") { text.removeLast() }
+ return text
+ }
+
private static func isColor(_ value: String) -> Bool {
value.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil
}
diff --git a/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift b/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift
index 5c2e12bb..1a192a00 100644
--- a/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift
+++ b/apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift
@@ -24,6 +24,13 @@ public enum GhostteaRendererPostProcess: String, Codable, Sendable {
case betterCRT = "better-crt"
}
+/// Whether a light theme may restyle applications that paint their own dark
+/// backgrounds (`ghosttea-light-adaptation`).
+public enum GhostteaLightAdaptation: String, Codable, Sendable {
+ case auto
+ case off
+}
+
public enum GhostteaShaderEffect: String, CaseIterable, Codable, Sendable {
case betterCRT = "ghosttea:better-crt"
case crt = "ghosttea:crt"
@@ -113,6 +120,9 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable {
public let palette: [GhostteaPaletteConfigEntry]
public let backgroundOpacity: Float
public let backgroundOpacityCells: Bool
+ /// WCAG contrast ratio text keeps against its cell background (1 = off).
+ public let minimumContrast: Float
+ public let lightAdaptation: GhostteaLightAdaptation
public let fontSize: Float
public let fontFamilies: [String]
public let paddingX: [Float]
@@ -125,6 +135,7 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case foreground, background, cursor, cursorText, selectionBackground, selectionForeground
case palette, backgroundOpacity, backgroundOpacityCells
+ case minimumContrast, lightAdaptation
case fontSize, fontFamilies, paddingX, paddingY, postProcess
case shaderEffects, customShaderAnimation, customShaderPaths
}
@@ -141,6 +152,9 @@ public struct GhostteaResolvedRendererConfig: Codable, Equatable, Sendable {
backgroundOpacity = try values.decodeIfPresent(Float.self, forKey: .backgroundOpacity) ?? 1
backgroundOpacityCells =
try values.decodeIfPresent(Bool.self, forKey: .backgroundOpacityCells) ?? false
+ minimumContrast = try values.decodeIfPresent(Float.self, forKey: .minimumContrast) ?? 1
+ lightAdaptation =
+ try values.decodeIfPresent(GhostteaLightAdaptation.self, forKey: .lightAdaptation) ?? .auto
fontSize = try values.decode(Float.self, forKey: .fontSize)
fontFamilies = try values.decode([String].self, forKey: .fontFamilies)
paddingX = try values.decode([Float].self, forKey: .paddingX)
@@ -169,6 +183,9 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable {
public let palette: [GhostteaPaletteConfigEntry]
public let backgroundOpacity: Float
public let backgroundOpacityCells: Bool
+ /// WCAG contrast ratio text keeps against its cell background (1 = off).
+ public let minimumContrast: Float
+ public let lightAdaptation: GhostteaLightAdaptation
public let fontSize: Float
public let fontFamilies: [String]
public let paddingX: [Float]
@@ -191,6 +208,8 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable {
palette: [GhostteaPaletteConfigEntry] = [],
backgroundOpacity: Float = 1,
backgroundOpacityCells: Bool = false,
+ minimumContrast: Float = 1,
+ lightAdaptation: GhostteaLightAdaptation = .auto,
fontSize: Float,
fontFamilies: [String],
paddingX: [Float],
@@ -211,6 +230,8 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable {
self.palette = palette
self.backgroundOpacity = backgroundOpacity
self.backgroundOpacityCells = backgroundOpacityCells
+ self.minimumContrast = minimumContrast
+ self.lightAdaptation = lightAdaptation
self.fontSize = fontSize
self.fontFamilies = fontFamilies
self.paddingX = paddingX
@@ -225,6 +246,7 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable {
case schemaVersion, revision, foreground, background, cursor, cursorText
case selectionBackground, selectionForeground, palette
case backgroundOpacity, backgroundOpacityCells
+ case minimumContrast, lightAdaptation
case fontSize, fontFamilies, paddingX, paddingY, postProcess
case shaderEffects, customShaderAnimation, customShaderCount
}
@@ -243,6 +265,9 @@ public struct GhostteaTerminalPresentationConfig: Codable, Equatable, Sendable {
backgroundOpacity = try values.decodeIfPresent(Float.self, forKey: .backgroundOpacity) ?? 1
backgroundOpacityCells =
try values.decodeIfPresent(Bool.self, forKey: .backgroundOpacityCells) ?? false
+ minimumContrast = try values.decodeIfPresent(Float.self, forKey: .minimumContrast) ?? 1
+ lightAdaptation =
+ try values.decodeIfPresent(GhostteaLightAdaptation.self, forKey: .lightAdaptation) ?? .auto
fontSize = try values.decode(Float.self, forKey: .fontSize)
fontFamilies = try values.decode([String].self, forKey: .fontFamilies)
paddingX = try values.decode([Float].self, forKey: .paddingX)
@@ -310,6 +335,8 @@ public struct GhostteaConfigSnapshot: Codable, Equatable, Sendable {
palette: renderer.palette,
backgroundOpacity: renderer.backgroundOpacity,
backgroundOpacityCells: renderer.backgroundOpacityCells,
+ minimumContrast: renderer.minimumContrast,
+ lightAdaptation: renderer.lightAdaptation,
fontSize: renderer.fontSize,
fontFamilies: renderer.fontFamilies,
paddingX: renderer.paddingX,
diff --git a/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift b/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift
index 582a3eca..b909f203 100644
--- a/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift
+++ b/apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift
@@ -37,6 +37,7 @@ public struct TRF1SectionKind: RawRepresentable, Hashable, Sendable {
public static let viewportMetadata = Self(rawValue: 9)
public static let accessibilityText = Self(rawValue: 10)
public static let clipboardWrite = Self(rawValue: 11)
+ public static let stylePalette = Self(rawValue: 13)
}
public struct TRF1Section: Sendable {
@@ -122,6 +123,45 @@ public struct TRF1StyleDefinition: Equatable, Sendable {
public let underline: Bool
public let foreground: TRF1RGB?
public let background: TRF1RGB?
+ /// Palette index `foreground` was resolved from, when it came from the palette.
+ public let foregroundPalette: UInt8?
+ /// Palette index `background` was resolved from, when it came from the palette.
+ public let backgroundPalette: UInt8?
+
+ public init(
+ id: UInt32,
+ bold: Bool,
+ italic: Bool,
+ faint: Bool,
+ inverse: Bool,
+ invisible: Bool,
+ strikethrough: Bool,
+ underline: Bool,
+ foreground: TRF1RGB?,
+ background: TRF1RGB?,
+ foregroundPalette: UInt8? = nil,
+ backgroundPalette: UInt8? = nil
+ ) {
+ self.id = id
+ self.bold = bold
+ self.italic = italic
+ self.faint = faint
+ self.inverse = inverse
+ self.invisible = invisible
+ self.strikethrough = strikethrough
+ self.underline = underline
+ self.foreground = foreground
+ self.background = background
+ self.foregroundPalette = foregroundPalette
+ self.backgroundPalette = backgroundPalette
+ }
+}
+
+/// Palette provenance for one style definition (TRF1 section 13).
+public struct TRF1StylePaletteEntry: Equatable, Sendable {
+ public let styleID: UInt32
+ public let foreground: UInt8?
+ public let background: UInt8?
}
public enum TRF1CursorStyle: UInt8, Sendable {
@@ -421,7 +461,12 @@ public func decodeTRF1GlyphDefinitions(_ section: TRF1Section) throws -> [TRF1Gl
return definitions
}
-public func decodeTRF1StyleDefinitions(_ section: TRF1Section) throws -> [TRF1StyleDefinition] {
+/// Decodes style definitions. Pass the frame's optional style palette section
+/// to attach palette provenance; the RGB colors are complete without it.
+public func decodeTRF1StyleDefinitions(
+ _ section: TRF1Section,
+ palette paletteSection: TRF1Section? = nil
+) throws -> [TRF1StyleDefinition] {
try requireKind(section, .styleDefinitions, "wrong style definition section kind")
let reader = TRF1Reader(data: section.bytes)
try reader.require(offset: 0, length: 4, "truncated style definition header")
@@ -469,7 +514,55 @@ public func decodeTRF1StyleDefinitions(_ section: TRF1Section) throws -> [TRF1St
)
)
}
- return styles
+ guard let paletteSection else { return styles }
+ var palette: [UInt32: TRF1StylePaletteEntry] = [:]
+ for entry in try decodeTRF1StylePalette(paletteSection) {
+ palette[entry.styleID] = entry
+ }
+ return styles.map { style in
+ guard let entry = palette[style.id] else { return style }
+ return TRF1StyleDefinition(
+ id: style.id,
+ bold: style.bold,
+ italic: style.italic,
+ faint: style.faint,
+ inverse: style.inverse,
+ invisible: style.invisible,
+ strikethrough: style.strikethrough,
+ underline: style.underline,
+ foreground: style.foreground,
+ background: style.background,
+ foregroundPalette: style.foreground == nil ? nil : entry.foreground,
+ backgroundPalette: style.background == nil ? nil : entry.background
+ )
+ }
+}
+
+public func decodeTRF1StylePalette(_ section: TRF1Section) throws -> [TRF1StylePaletteEntry] {
+ try requireKind(section, .stylePalette, "wrong style palette section kind")
+ let reader = TRF1Reader(data: section.bytes)
+ try reader.require(offset: 0, length: 4, "truncated style palette header")
+ let count = try reader.uint32(0, "truncated style palette header")
+ try require(count == section.itemCount, "style palette count mismatch")
+ try require(Int(count) <= (section.bytes.count - 4) / 8, "invalid style palette length")
+ try require(section.bytes.count == 4 + Int(count) * 8, "invalid style palette length")
+ var entries: [TRF1StylePaletteEntry] = []
+ entries.reserveCapacity(Int(count))
+ for index in 0.. TRF1CursorState {
diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift
new file mode 100644
index 00000000..4551022a
--- /dev/null
+++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaLightAdaptation.swift
@@ -0,0 +1,483 @@
+// Light adaptation: render a dark-painted application (one that fills the
+// screen with its own truecolor backgrounds) as a light design under a light
+// theme. Colors are remapped per style before blending, so glyph coverage,
+// color emoji, and the shader stack are untouched.
+//
+// This is a port of `packages/ghosttea-react/src/renderers/light-adaptation.ts`
+// and must stay numerically in step with it; the tests share expected values.
+//
+// surfaces mirror around the paper in Lr (OKLab lightness with Ottosson's
+// toe, which tracks CIE L*): a panel 0.05 above the app's base
+// lands 0.05 below the theme background; the base's tint becomes
+// the paper's. Tinted rows step down until a visible tint fits.
+// neutral ink keeps its Lr distance from its own cell background, mirrored,
+// so primary/secondary/border grays keep their order.
+// accents land in a mid-tone band: APCA Lc = clamp(0.5·|Lc| + 28, 45, 70)
+// against the new background, chroma raised toward 0.12.
+//
+// Theme-owned colors (default fg/bg) are left to the theme, and so are
+// chromatic ANSI colors (palette 1–6, 9–14): the light theme designed them for
+// light paper, so they pass through unless the remapped surface beneath makes
+// them less legible than on the theme background. Neutral ANSI colors (0, 7,
+// 8, 15) carry a role — "bright text", "dim panel" — rather than a hue, so
+// they remap like truecolor.
+
+import Foundation
+import GhostteaFrame
+
+struct GhostteaLightRGB: Hashable, Sendable {
+ let red: UInt8
+ let green: UInt8
+ let blue: UInt8
+
+ init(_ red: UInt8, _ green: UInt8, _ blue: UInt8) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ init(_ color: TRF1RGB) {
+ self.init(color.red, color.green, color.blue)
+ }
+
+ init(_ color: GhostteaMetalColor) {
+ func byte(_ value: Float) -> UInt8 { UInt8((Double(value).clamped() * 255).rounded()) }
+ self.init(byte(color.red), byte(color.green), byte(color.blue))
+ }
+
+ var metalColor: GhostteaMetalColor {
+ GhostteaMetalColor(
+ red: Float(red) / 255, green: Float(green) / 255, blue: Float(blue) / 255, alpha: 1)
+ }
+
+ var hex: String { String((Int(red) << 16) | (Int(green) << 8) | Int(blue), radix: 16) }
+}
+
+private typealias Lab = (L: Double, a: Double, b: Double)
+
+extension Double {
+ fileprivate func clamped(_ lo: Double = 0, _ hi: Double = 1) -> Double {
+ Swift.min(hi, Swift.max(lo, self))
+ }
+}
+
+private func toLinear(_ v: Double) -> Double {
+ v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
+}
+
+private func toGamma(_ v: Double) -> Double {
+ v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
+}
+
+private func oklab(_ color: GhostteaLightRGB) -> Lab {
+ let r = toLinear(Double(color.red) / 255)
+ let g = toLinear(Double(color.green) / 255)
+ let b = toLinear(Double(color.blue) / 255)
+ let l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
+ let m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
+ let s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
+ return (
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
+ )
+}
+
+private func oklabToLinear(_ lab: Lab) -> (Double, Double, Double) {
+ let l1 = lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b
+ let m1 = lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b
+ let s1 = lab.L - 0.0894841775 * lab.a - 1.291485548 * lab.b
+ let l = l1 * l1 * l1
+ let m = m1 * m1 * m1
+ let s = s1 * s1 * s1
+ return (
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
+ )
+}
+
+private func inGamut(_ linear: (Double, Double, Double)) -> Bool {
+ [linear.0, linear.1, linear.2].allSatisfy { $0 >= -1e-4 && $0 <= 1 + 1e-4 }
+}
+
+private func fromLch(_ L: Double, _ C: Double, _ h: Double) -> Lab {
+ (L, C * cos(h), C * sin(h))
+}
+
+private func toRgb(_ lab: Lab) -> GhostteaLightRGB {
+ let linear = oklabToLinear(lab)
+ func byte(_ v: Double) -> UInt8 { UInt8((toGamma(v.clamped()).clamped() * 255).rounded()) }
+ return GhostteaLightRGB(byte(linear.0), byte(linear.1), byte(linear.2))
+}
+
+/// Keep L and hue; reduce chroma until the color fits sRGB.
+private func gamutMap(_ lightness: Double, _ C: Double, _ h: Double) -> GhostteaLightRGB {
+ let L = lightness.clamped()
+ if inGamut(oklabToLinear(fromLch(L, C, h))) { return toRgb(fromLch(L, C, h)) }
+ var lo = 0.0
+ var hi = C
+ for _ in 0..<20 {
+ let mid = (lo + hi) / 2
+ if inGamut(oklabToLinear(fromLch(L, mid, h))) { lo = mid } else { hi = mid }
+ }
+ return toRgb(fromLch(L, lo, h))
+}
+
+private func maxChroma(_ L: Double, _ h: Double) -> Double {
+ var lo = 0.0
+ var hi = 0.4
+ for _ in 0..<16 {
+ let mid = (lo + hi) / 2
+ if inGamut(oklabToLinear(fromLch(L, mid, h))) { lo = mid } else { hi = mid }
+ }
+ return lo
+}
+
+private let k1 = 0.206
+private let k2 = 0.03
+private let k3 = (1 + k1) / (1 + k2)
+
+private func toe(_ L: Double) -> Double {
+ 0.5 * (k3 * L - k1 + ((k3 * L - k1) * (k3 * L - k1) + 4 * k2 * k3 * L).squareRoot())
+}
+
+private func toeInverse(_ Lr: Double) -> Double {
+ (Lr * Lr + k1 * Lr) / (k3 * (Lr + k2))
+}
+
+// APCA 0.0.98G-4g. Positive Lc = dark text on a light background.
+private func apcaY(_ color: GhostteaLightRGB) -> Double {
+ let y =
+ 0.2126729 * pow(Double(color.red) / 255, 2.4)
+ + 0.7151522 * pow(Double(color.green) / 255, 2.4)
+ + 0.072175 * pow(Double(color.blue) / 255, 2.4)
+ return y < 0.022 ? y + pow(0.022 - y, 1.414) : y
+}
+
+private func apca(_ text: GhostteaLightRGB, _ background: GhostteaLightRGB) -> Double {
+ let yt = apcaY(text)
+ let yb = apcaY(background)
+ if abs(yb - yt) < 0.0005 { return 0 }
+ if yb > yt {
+ let sapc = (pow(yb, 0.56) - pow(yt, 0.57)) * 1.14
+ return sapc < 0.1 ? 0 : (sapc - 0.027) * 100
+ }
+ let sapc = (pow(yb, 0.65) - pow(yt, 0.62)) * 1.14
+ return sapc > -0.1 ? 0 : (sapc + 0.027) * 100
+}
+
+private func smoothstep(_ e0: Double, _ e1: Double, _ x: Double) -> Double {
+ let t = ((x - e0) / (e1 - e0)).clamped()
+ return t * t * (3 - 2 * t)
+}
+
+private func luminance(_ color: GhostteaLightRGB) -> Double {
+ 0.2126 * toLinear(Double(color.red) / 255)
+ + 0.7152 * toLinear(Double(color.green) / 255)
+ + 0.0722 * toLinear(Double(color.blue) / 255)
+}
+
+private func contrastRatio(_ a: GhostteaLightRGB, _ b: GhostteaLightRGB) -> Double {
+ let first = luminance(a)
+ let second = luminance(b)
+ return (max(first, second) + 0.05) / (min(first, second) + 0.05)
+}
+
+/// Results of `ensureContrast`, shared across renderers like the TypeScript
+/// module-level map. `nil` values mean the text already meets the ratio.
+private final class GhostteaContrastCache: @unchecked Sendable {
+ struct Key: Hashable {
+ let text: GhostteaLightRGB
+ let background: GhostteaLightRGB
+ let ratio: Float
+ }
+
+ static let shared = GhostteaContrastCache()
+
+ private let lock = NSLock()
+ private var entries: [Key: GhostteaLightRGB?] = [:]
+
+ func value(for key: Key, compute: () -> GhostteaLightRGB?) -> GhostteaLightRGB? {
+ if let cached = lock.withLock({ entries[key] }) { return cached }
+ let result = compute()
+ lock.withLock {
+ if entries.count > 4096 { entries.removeAll(keepingCapacity: true) }
+ entries[key] = .some(result)
+ }
+ return result
+ }
+}
+
+enum GhostteaLightColor {
+ /// Engage when this share of cells carries an explicit dark background…
+ static let engageShare = 0.6
+ /// …and stay engaged until it drops below this, so scrolling content does not flicker.
+ static let releaseShare = 0.35
+
+ /// Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim.
+ static func isLightBackground(_ color: GhostteaLightRGB) -> Bool {
+ let y = luminance(color)
+ return (y + 0.05) / 0.05 > 1.05 / (y + 0.05)
+ }
+
+ /// Ghostty's `minimum-contrast`: text below the WCAG ratio against its
+ /// background moves toward whichever of black or white contrasts more.
+ /// Ghostty snaps straight to that extreme; this keeps hue and chroma and
+ /// moves OKLab lightness only as far as the ratio needs, reaching black or
+ /// white only when nothing less suffices.
+ static func ensureContrast(
+ _ text: GhostteaMetalColor,
+ background: GhostteaMetalColor,
+ ratio: Float
+ ) -> GhostteaMetalColor {
+ guard ratio > 1 else { return text }
+ let fg = GhostteaLightRGB(text)
+ let bg = GhostteaLightRGB(background)
+ let target = Double(ratio)
+ let result = GhostteaContrastCache.shared.value(
+ for: .init(text: fg, background: bg, ratio: ratio)
+ ) { () -> GhostteaLightRGB? in
+ if contrastRatio(fg, bg) >= target { return nil }
+ let white = GhostteaLightRGB(255, 255, 255)
+ let black = GhostteaLightRGB(0, 0, 0)
+ let towardWhite = contrastRatio(white, bg) > contrastRatio(black, bg)
+ let lab = oklab(fg)
+ let C = hypot(lab.a, lab.b)
+ let h = atan2(lab.b, lab.a)
+ var near = lab.L
+ var far: Double = towardWhite ? 1 : 0
+ var best = towardWhite ? white : black
+ if contrastRatio(best, bg) >= target {
+ for _ in 0..<20 {
+ let mid = (near + far) / 2
+ let candidate = gamutMap(mid, C, h)
+ if contrastRatio(candidate, bg) >= target {
+ far = mid
+ best = candidate
+ } else {
+ near = mid
+ }
+ }
+ }
+ return best
+ }
+ guard let result else { return text }
+ return GhostteaMetalColor(
+ red: Float(result.red) / 255,
+ green: Float(result.green) / 255,
+ blue: Float(result.blue) / 255,
+ alpha: text.alpha
+ )
+ }
+
+ /// The surface a dark-painted application uses as its base, or nil when the
+ /// pane should render as-is: adaptation is off, the theme is dark, or too few
+ /// cells carry an explicit dark background (the app follows the theme).
+ static func darkPaintedBase(
+ state: RetainedTRF1State,
+ theme: GhostteaMetalTheme,
+ engaged: Bool
+ ) -> GhostteaLightRGB? {
+ darkPaintedBase(
+ styleRows: state.rows.lazy.map(\.styles),
+ rowCount: state.rows.count,
+ columns: Int(state.columns),
+ styleDefinitions: state.styleDefinitions,
+ theme: theme,
+ engaged: engaged
+ )
+ }
+
+ static func darkPaintedBase(
+ styleRows: some Sequence<[TRF1StyleRun]>,
+ rowCount: Int,
+ columns: Int,
+ styleDefinitions: [UInt32: TRF1StyleDefinition],
+ theme: GhostteaMetalTheme,
+ engaged: Bool
+ ) -> GhostteaLightRGB? {
+ guard theme.lightAdaptation, isLightBackground(GhostteaLightRGB(theme.background)) else {
+ return nil
+ }
+ let total = rowCount * columns
+ guard total > 0 else { return nil }
+ var areas: [GhostteaLightRGB: Int] = [:]
+ var order: [GhostteaLightRGB] = []
+ var darkness: [GhostteaLightRGB: Bool] = [:]
+ var dark = 0
+ for runs in styleRows {
+ for run in runs {
+ let style = styleDefinitions[run.styleID]
+ guard let raw = style?.inverse == true ? style?.foreground : style?.background else {
+ continue
+ }
+ let background = GhostteaLightRGB(raw)
+ let isDark: Bool
+ if let known = darkness[background] {
+ isDark = known
+ } else {
+ isDark = !isLightBackground(background)
+ darkness[background] = isDark
+ }
+ guard isDark else { continue }
+ dark += Int(run.cellSpan)
+ if let area = areas[background] {
+ areas[background] = area + Int(run.cellSpan)
+ } else {
+ areas[background] = Int(run.cellSpan)
+ order.append(background)
+ }
+ }
+ }
+ guard Double(dark) / Double(total) >= (engaged ? releaseShare : engageShare) else {
+ return nil
+ }
+ var best: GhostteaLightRGB?
+ var bestArea = -1
+ for color in order where areas[color]! > bestArea {
+ best = color
+ bestArea = areas[color]!
+ }
+ return best
+ }
+}
+
+/// The remap for one (application base, theme paper, theme ink) triple. Its
+/// caches make repeated lookups of the same color cheap, so a renderer keeps
+/// one instance per key rather than rebuilding it per frame.
+final class GhostteaLightRemap {
+ private static let chromaticANSI: Set = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]
+
+ private struct InkKey: Hashable {
+ let color: GhostteaLightRGB
+ let sourceBackground: GhostteaLightRGB
+ let themeColor: Bool
+ }
+
+ /// Stable identity for cache keys: base, paper, and ink.
+ let key: String
+ let base: GhostteaLightRGB
+ private let paper: GhostteaLightRGB
+ private let baseLab: Lab
+ private let paperLab: Lab
+ private let inkLab: Lab
+ private let baseLr: Double
+ private let paperLr: Double
+ private var surfaces: [GhostteaLightRGB: GhostteaLightRGB] = [:]
+ private var inks: [InkKey: GhostteaMetalColor] = [:]
+
+ init(
+ base: GhostteaLightRGB, paper paperColor: GhostteaMetalColor, ink inkColor: GhostteaMetalColor
+ ) {
+ let paper = GhostteaLightRGB(paperColor)
+ let ink = GhostteaLightRGB(inkColor)
+ self.base = base
+ self.paper = paper
+ baseLab = oklab(base)
+ paperLab = oklab(paper)
+ inkLab = oklab(ink)
+ baseLr = toe(baseLab.L)
+ paperLr = toe(paperLab.L)
+ key = [base, paper, ink].map(\.hex).joined(separator: "/")
+ }
+
+ /// A color painted as an area: cell backgrounds and block-element art.
+ func surface(_ color: GhostteaLightRGB) -> GhostteaMetalColor {
+ surfaceRGB(color).metalColor
+ }
+
+ /// A glyph color, judged against the background it was designed on.
+ /// `paletteIndex` is the ANSI entry the color came from, when known.
+ func ink(
+ _ color: GhostteaLightRGB,
+ sourceBackground: GhostteaLightRGB,
+ paletteIndex: UInt8? = nil
+ ) -> GhostteaMetalColor {
+ let themeColor = paletteIndex.map(Self.chromaticANSI.contains) ?? false
+ let cacheKey = InkKey(color: color, sourceBackground: sourceBackground, themeColor: themeColor)
+ if let cached = inks[cacheKey] { return cached }
+ let mapped = surfaceRGB(sourceBackground)
+ if themeColor {
+ let legibility = min(45, abs(apca(color, paper)))
+ if abs(apca(color, mapped)) >= legibility {
+ let rgba = color.metalColor
+ inks[cacheKey] = rgba
+ return rgba
+ }
+ }
+ let lab = oklab(color)
+ let C0 = hypot(lab.a, lab.b)
+ // Neutral ink: mirror its Lr distance from the background, and lean
+ // toward the theme ink's tint so app grays sit in the chosen theme.
+ var a = lab.a
+ var b = lab.b
+ if C0 < 0.03 {
+ let w = (1 - C0 / 0.03) * 0.7
+ a = lab.a * (1 - w) + inkLab.a * w
+ b = lab.b * (1 - w) + inkLab.b * w
+ }
+ let C = hypot(a, b)
+ let h = atan2(b, a)
+ let delta = toe(lab.L) - toe(oklab(sourceBackground).L)
+ let sign: Double = delta >= 0 ? -1 : 1
+ let Lr = (toe(oklab(mapped).L) + sign * max(abs(delta), 0.06)).clamped()
+ var result = gamutMap(toeInverse(Lr), C, h)
+ let w = smoothstep(0.03, 0.08, C0)
+ if w > 0 {
+ let source = apca(color, sourceBackground)
+ let lightOnDark = source < 0 || (source == 0 && delta >= 0)
+ let magnitude = (abs(source) * 0.5 + 28).clamped(45, 70)
+ let boosted = C0 + w * (0.12 - C0).clamped(0, C0 * 0.8)
+ let accent = solveLc(
+ lightOnDark ? magnitude : -magnitude, boosted, atan2(lab.b, lab.a), mapped)
+ if w >= 1 {
+ result = accent
+ } else {
+ let p = oklab(result)
+ let q = oklab(accent)
+ result = toRgb(
+ (p.L * (1 - w) + q.L * w, p.a * (1 - w) + q.a * w, p.b * (1 - w) + q.b * w))
+ }
+ }
+ let rgba = result.metalColor
+ inks[cacheKey] = rgba
+ return rgba
+ }
+
+ private func surfaceRGB(_ color: GhostteaLightRGB) -> GhostteaLightRGB {
+ if let cached = surfaces[color] { return cached }
+ let lab = oklab(color)
+ var Lr = (paperLr - (toe(lab.L) - baseLr)).clamped()
+ let ap = lab.a - baseLab.a + paperLab.a
+ let bp = lab.b - baseLab.b + paperLab.b
+ let C = hypot(ap, bp)
+ let h = atan2(bp, ap)
+ if C > 0.015 {
+ let want = min(C, 0.045)
+ let stop = Lr - 0.15
+ while Lr > stop && maxChroma(toeInverse(Lr), h) < want { Lr -= 0.005 }
+ }
+ let result = gamutMap(toeInverse(Lr), C, h)
+ surfaces[color] = result
+ return result
+ }
+
+ private func solveLc(
+ _ want: Double, _ C: Double, _ h: Double, _ background: GhostteaLightRGB
+ ) -> GhostteaLightRGB {
+ let backgroundL = oklab(background).L
+ var lo = want > 0 ? 0 : backgroundL
+ var hi = want > 0 ? backgroundL : 1
+ var best = gamutMap(want > 0 ? 0 : 1, C, h)
+ for _ in 0..<18 {
+ let mid = (lo + hi) / 2
+ let candidate = gamutMap(mid, C, h)
+ let lc = apca(candidate, background)
+ let enough = want > 0 ? lc >= want : lc <= want
+ if enough { best = candidate }
+ if (want > 0) == enough { lo = mid } else { hi = mid }
+ }
+ return best
+ }
+}
diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift
index 26c4e9eb..42474e63 100644
--- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift
+++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaMetalRenderer.swift
@@ -12,6 +12,10 @@ struct GhostteaMetalColor: Equatable, Sendable {
static let clear = Self(red: 0, green: 0, blue: 0, alpha: 0)
var components: [Float] { [red, green, blue, alpha] }
+
+ func withAlpha(_ alpha: Float) -> Self {
+ Self(red: red, green: green, blue: blue, alpha: alpha)
+ }
}
struct GhostteaMetalTheme: Equatable, Sendable {
@@ -25,6 +29,11 @@ struct GhostteaMetalTheme: Equatable, Sendable {
var backgroundOpacityCells = false
var shaderEffects: [GhostteaMetalShaderEffect] = []
var shaderAnimation = false
+ /// Restyle panes a dark-painted application fills under a light theme
+ /// (`ghosttea-light-adaptation = auto`).
+ var lightAdaptation = false
+ /// WCAG ratio text keeps against its cell background (`minimum-contrast`, 1 = off).
+ var minimumContrast: Float = 1
}
enum GhostteaMetalShaderEffect: UInt32, CaseIterable, Equatable, Sendable {
@@ -126,6 +135,10 @@ struct GhostteaMetalDrawResult: Equatable, Sendable {
private struct GhostteaResolvedMetalStyle {
let foreground: GhostteaMetalColor
let background: GhostteaMetalColor?
+ /// Foreground used as an area fill (block elements); differs only under light adaptation.
+ let fill: GhostteaMetalColor
+ /// Foreground for text glyphs and their decorations, after `minimum-contrast`.
+ let text: GhostteaMetalColor
let underline: Bool
let strikethrough: Bool
let invisible: Bool
@@ -174,6 +187,7 @@ private struct GhostteaMetalRowCacheContext: Equatable {
let theme: GhostteaMetalTheme
let contentInsets: GhostteaTerminalContentInsets
let selection: GhostteaMetalSelection?
+ let lightAdaptationKey: String?
let alphaAtlasResetCount: Int
let colorAtlasResetCount: Int
}
@@ -224,6 +238,7 @@ private struct GhostteaMetalGeometryKey: Equatable {
let contentInsets: GhostteaTerminalContentInsets
let selection: GhostteaMetalSelection?
let focused: Bool
+ let lightAdaptationKey: String?
let alphaAtlasResetCount: Int
let colorAtlasResetCount: Int
}
@@ -421,6 +436,9 @@ final class GhostteaMetalRenderer {
private var rowCache: [Int: GhostteaMetalRowCacheEntry] = [:]
private var pendingRowRevisions: [Int: UInt64] = [:]
private var rowCacheBytes = 0
+ private var lightRemap: GhostteaLightRemap?
+ private var lightRemaps: [String: GhostteaLightRemap] = [:]
+ private var lightRemapOrder: [String] = []
private var effectSceneTexture: (any MTLTexture)?
private var effectIntermediateTextures: [any MTLTexture] = []
private var effectTextureSize = SIMD2(repeating: 0)
@@ -609,6 +627,7 @@ final class GhostteaMetalRenderer {
Float((focused ? $0.style : TRF1CursorStyle.hollowBlock).rawValue)
)
} ?? SIMD4(repeating: 0)
+ prepareLightAdaptation(state: state, theme: theme)
let recorder = GhostteaPerformanceRecorder.shared
let lookupKey =
encodedGeometryReuseEnabled
@@ -762,11 +781,37 @@ final class GhostteaMetalRenderer {
contentInsets: contentInsets,
selection: selection,
focused: focused,
+ lightAdaptationKey: lightRemap?.key,
alphaAtlasResetCount: atlases.alpha.resetCount,
colorAtlasResetCount: atlases.colorResetCount
)
}
+ /// Engages, re-keys, or releases light adaptation for this pane. The remap
+ /// key is part of the geometry key and row-cache context, so any change
+ /// rebuilds every row with the new colors.
+ private func prepareLightAdaptation(state: RetainedTRF1State, theme: GhostteaMetalTheme) {
+ guard
+ let base = GhostteaLightColor.darkPaintedBase(
+ state: state, theme: theme, engaged: lightRemap != nil)
+ else {
+ lightRemap = nil
+ return
+ }
+ let key = "\(base.hex)|\(theme.background.components)|\(theme.foreground.components)"
+ if let cached = lightRemaps[key] {
+ lightRemap = cached
+ return
+ }
+ let remap = GhostteaLightRemap(base: base, paper: theme.background, ink: theme.foreground)
+ lightRemaps[key] = remap
+ lightRemapOrder.append(key)
+ if lightRemapOrder.count > 8 {
+ lightRemaps[lightRemapOrder.removeFirst()] = nil
+ }
+ lightRemap = remap
+ }
+
private func visibleGlyphDefinitions(_ state: RetainedTRF1State) throws -> [TRF1GlyphDefinition] {
var ids: Set = []
for row in state.rows {
@@ -820,6 +865,7 @@ final class GhostteaMetalRenderer {
theme: theme,
contentInsets: contentInsets,
selection: orderedSelection,
+ lightAdaptationKey: lightRemap?.key,
alphaAtlasResetCount: atlases.alpha.resetCount,
colorAtlasResetCount: atlases.colorResetCount
)
@@ -837,6 +883,8 @@ final class GhostteaMetalRenderer {
activity.evictions += clearRowCache()
pendingRowRevisions.removeAll(keepingCapacity: true)
}
+ let styles = GhostteaMetalStyleResolver(
+ definitions: state.styleDefinitions, theme: theme, adaptation: lightRemap)
for (rowIndex, row) in state.rows.enumerated() {
let rowDamaged = damage.rows.contains(UInt16(clamping: rowIndex))
@@ -865,7 +913,8 @@ final class GhostteaMetalRenderer {
originX: originX,
originY: originY,
selection: orderedSelection,
- blockCursorColumn: blockCursorColumn
+ blockCursorColumn: blockCursorColumn,
+ styles: styles
)
append(rowMesh, to: &mesh)
if !broadDamage, pendingRowRevisions[rowIndex] == row.revision,
@@ -982,11 +1031,18 @@ final class GhostteaMetalRenderer {
originX: Float,
originY: Float,
selection: GhostteaMetalSelection?,
- blockCursorColumn: UInt16?
+ blockCursorColumn: UInt16?,
+ styles: GhostteaMetalStyleResolver
) throws -> GhostteaMetalRowMesh {
var mesh = GhostteaMetalRowMesh()
+ // Graphics (box drawing, block elements) keep the raw foreground rather
+ // than the contrast-adjusted text color, as in Ghostty; block elements
+ // are areas, so light adaptation maps them as surfaces. Only computed
+ // when those colors can differ.
+ let graphics =
+ styles.distinguishesGraphics ? GhostteaMetalGraphicCells.cells(in: row.text) : [:]
for run in row.styles {
- let style = resolveStyle(state.styleDefinitions[run.styleID], theme: theme)
+ let style = styles[run.styleID]
if let background = style.background {
pushRectangle(
into: &mesh.backgrounds,
@@ -1002,7 +1058,7 @@ final class GhostteaMetalRenderer {
}
for instance in row.glyphs {
guard let definition = state.glyphDefinitions[instance.glyphID] else { continue }
- let style = resolveStyle(state.styleDefinitions[instance.styleID], theme: theme)
+ let style = styles[instance.styleID]
if style.invisible { continue }
let glyphStart = Int(instance.cellStart)
let glyphEnd = glyphStart + max(1, Int(instance.cellSpan))
@@ -1010,14 +1066,22 @@ final class GhostteaMetalRenderer {
blockCursorColumn.map {
glyphStart <= Int($0) && Int($0) < glyphEnd
} ?? false
- let foreground =
- selectionContains(selection, row: rowIndex, column: glyphStart)
- ? theme.selectionForeground
- : style.foreground
+ let selected = selectionContains(selection, row: rowIndex, column: glyphStart)
+ let foreground = selected ? theme.selectionForeground : style.foreground
guard let location = atlases.location(for: definition) else {
throw TRF1DecodingError("visible glyph \(definition.id) is absent from its atlas")
}
if definition.format == .alpha8 {
+ let glyphColor: GhostteaMetalColor
+ if selected {
+ glyphColor = foreground
+ } else {
+ switch graphics[glyphStart] {
+ case .box: glyphColor = style.foreground
+ case .block: glyphColor = style.fill
+ case nil: glyphColor = style.text
+ }
+ }
pushGlyph(
into: &mesh.alphaGlyphs,
x: (originX + instance.x) * scale,
@@ -1025,7 +1089,7 @@ final class GhostteaMetalRenderer {
width: instance.width * scale,
height: instance.height * scale,
location: location,
- color: foreground,
+ color: glyphColor,
viewportWidth: width,
viewportHeight: height
)
@@ -1072,7 +1136,7 @@ final class GhostteaMetalRenderer {
}
}
for run in row.styles {
- let style = resolveStyle(state.styleDefinitions[run.styleID], theme: theme)
+ let style = styles[run.styleID]
if style.invisible { continue }
let x = (originX + Float(run.cellStart) * cellWidth) * scale
let rowTop = (originY + Float(rowIndex) * lineHeight) * scale
@@ -1091,7 +1155,7 @@ final class GhostteaMetalRenderer {
y: (rowTop + 16 * metricScale * scale).rounded(),
width: runWidth,
height: stroke,
- color: style.foreground,
+ color: style.text,
viewportWidth: width,
viewportHeight: height
)
@@ -1115,7 +1179,7 @@ final class GhostteaMetalRenderer {
y: (rowTop + 9 * metricScale * scale).rounded(),
width: runWidth,
height: stroke,
- color: style.foreground,
+ color: style.text,
viewportWidth: width,
viewportHeight: height
)
@@ -1884,16 +1948,116 @@ final class GhostteaMetalRenderer {
}
}
+/// Resolves each style once per mesh build. Light adaptation and
+/// `minimum-contrast` search colors, so per-glyph resolution would repeat that
+/// work for every cell sharing a style.
+private final class GhostteaMetalStyleResolver {
+ private let definitions: [UInt32: TRF1StyleDefinition]
+ private let theme: GhostteaMetalTheme
+ private let adaptation: GhostteaLightRemap?
+ private var resolved: [UInt32: GhostteaResolvedMetalStyle] = [:]
+
+ init(
+ definitions: [UInt32: TRF1StyleDefinition],
+ theme: GhostteaMetalTheme,
+ adaptation: GhostteaLightRemap?
+ ) {
+ self.definitions = definitions
+ self.theme = theme
+ self.adaptation = adaptation
+ }
+
+ /// Whether graphics cells can need a color other than the text color.
+ var distinguishesGraphics: Bool { adaptation != nil || theme.minimumContrast > 1 }
+
+ subscript(id: UInt32) -> GhostteaResolvedMetalStyle {
+ if let style = resolved[id] { return style }
+ let style = resolveStyle(definitions[id], theme: theme, adaptation: adaptation)
+ resolved[id] = style
+ return style
+ }
+}
+
+private enum GhostteaMetalGraphicCell {
+ case box
+ case block
+}
+
+enum GhostteaMetalGraphicCells {
+ /// Columns holding box-drawing (U+2500–257F) or block-element (U+2580–259F)
+ /// characters, found by walking the row text with the desktop's cell widths.
+ fileprivate static func cells(in text: String) -> [Int: GhostteaMetalGraphicCell] {
+ if text.utf8.allSatisfy({ $0 < 0x80 }) { return [:] }
+ var cells: [Int: GhostteaMetalGraphicCell] = [:]
+ var column = 0
+ for character in text {
+ switch character.unicodeScalars.first?.value ?? 0 {
+ case 0x2500...0x257F: cells[column] = .box
+ case 0x2580...0x259F: cells[column] = .block
+ default: break
+ }
+ column += cellWidth(character)
+ }
+ return cells
+ }
+
+ /// Mirrors `graphemeCellWidth` in `@vibecook/ghosttea-react`. Swift has no
+ /// Extended_Pictographic property, so non-ASCII emoji other than regional
+ /// indicators stand in for it.
+ static func cellWidth(_ character: Character) -> Int {
+ let pictographic = character.unicodeScalars.contains {
+ $0.value > 0x7F && $0.properties.isEmoji && !(0x1F1E6...0x1F1FF).contains($0.value)
+ }
+ if pictographic { return 2 }
+ switch character.unicodeScalars.first?.value ?? 0 {
+ case 0x1100...0x115F, 0x2E80...0xA4CF, 0xAC00...0xD7A3, 0xF900...0xFAFF, 0xFE10...0xFE6F,
+ 0xFF00...0xFF60, 0x1F300...0x1FAFF, 0x20000...0x3FFFD:
+ return 2
+ default:
+ return 1
+ }
+ }
+}
+
private func resolveStyle(
_ style: TRF1StyleDefinition?,
- theme: GhostteaMetalTheme
+ theme: GhostteaMetalTheme,
+ adaptation: GhostteaLightRemap? = nil
) -> GhostteaResolvedMetalStyle {
var foreground = style?.foreground.map(metalColor) ?? theme.foreground
var background = style?.background.map(metalColor)
- if style?.inverse == true {
+ var fill = foreground
+ if let adaptation {
+ // Application colors were designed on the app's dark base; theme-owned
+ // defaults already belong to the light theme and pass through.
+ let sourceForeground = style?.foreground.map { GhostteaLightRGB($0) }
+ let sourceBackground = style?.background.map { GhostteaLightRGB($0) }
+ if style?.inverse == true {
+ background = sourceForeground.map { adaptation.surface($0) } ?? theme.foreground
+ foreground =
+ sourceBackground.map {
+ adaptation.ink(
+ $0,
+ sourceBackground: sourceForeground ?? adaptation.base,
+ paletteIndex: style?.backgroundPalette)
+ } ?? theme.background
+ fill = foreground
+ } else {
+ foreground =
+ sourceForeground.map {
+ adaptation.ink(
+ $0,
+ sourceBackground: sourceBackground ?? adaptation.base,
+ paletteIndex: style?.foregroundPalette)
+ } ?? theme.foreground
+ background = sourceBackground.map { adaptation.surface($0) }
+ fill = sourceForeground.map { adaptation.surface($0) } ?? theme.foreground
+ }
+ } else if style?.inverse == true {
let originalForeground = foreground
foreground = background ?? theme.background
background = originalForeground
+ fill = foreground
}
if let cellBackground = background, theme.backgroundOpacityCells {
background = GhostteaMetalColor(
@@ -1903,23 +2067,49 @@ private func resolveStyle(
alpha: theme.background.alpha
)
}
+ // Graphics and color glyphs keep the raw foreground, as in Ghostty; the
+ // backdrop ignores window transparency.
+ var text = GhostteaLightColor.ensureContrast(
+ foreground,
+ background: over(background ?? theme.background, theme.background),
+ ratio: theme.minimumContrast
+ )
if style?.faint == true {
- foreground = GhostteaMetalColor(
- red: foreground.red,
- green: foreground.green,
- blue: foreground.blue,
- alpha: foreground.alpha * 0.55
- )
+ foreground = foreground.withAlpha(foreground.alpha * 0.55)
+ fill = fill.withAlpha(fill.alpha * 0.55)
+ text = text.withAlpha(text.alpha * 0.55)
}
return GhostteaResolvedMetalStyle(
foreground: foreground,
background: background,
+ fill: fill,
+ text: text,
underline: style?.underline ?? false,
strikethrough: style?.strikethrough ?? false,
invisible: style?.invisible ?? false
)
}
+/// Source-over composite of straight-alpha colors.
+private func over(
+ _ source: GhostteaMetalColor,
+ _ backdrop: GhostteaMetalColor
+) -> GhostteaMetalColor {
+ if source.alpha >= 1 { return source }
+ if source.alpha <= 0 { return backdrop }
+ let alpha = source.alpha + backdrop.alpha * (1 - source.alpha)
+ if alpha <= Float.ulpOfOne { return .clear }
+ func channel(_ s: Float, _ b: Float) -> Float {
+ (s * source.alpha + b * backdrop.alpha * (1 - source.alpha)) / alpha
+ }
+ return GhostteaMetalColor(
+ red: channel(source.red, backdrop.red),
+ green: channel(source.green, backdrop.green),
+ blue: channel(source.blue, backdrop.blue),
+ alpha: alpha
+ )
+}
+
private func metalColor(_ color: TRF1RGB) -> GhostteaMetalColor {
GhostteaMetalColor(
red: Float(color.red) / 255,
diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift
index 7b54e205..e1a9174f 100644
--- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift
+++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalFrameDecoder.swift
@@ -51,7 +51,9 @@ public enum GhostteaTerminalFrameDecoder {
case .glyphDefinitions:
glyphDefinitionCount += try decodeTRF1GlyphDefinitions(section).count
case .styleDefinitions:
- styleDefinitionCount += try decodeTRF1StyleDefinitions(section).count
+ styleDefinitionCount += try decodeTRF1StyleDefinitions(
+ section, palette: frame.sections.first { $0.kind == .stylePalette }
+ ).count
case .rowReplacements:
rowReplacementCount += try decodeTRF1RowReplacements(section).count
case .cursorState:
diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift
index 000cf11c..141a3ec6 100644
--- a/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift
+++ b/apple/GhostteaKit/Sources/GhostteaTerminal/GhostteaTerminalMetalView.swift
@@ -1488,7 +1488,10 @@
selectionForeground: selectionForeground,
backgroundOpacityCells: config.backgroundOpacityCells,
shaderEffects: effects,
- shaderAnimation: config.customShaderAnimation
+ shaderAnimation: config.customShaderAnimation,
+ lightAdaptation: config.lightAdaptation == .auto,
+ minimumContrast: config.minimumContrast.isFinite
+ ? min(21, max(1, config.minimumContrast)) : 1
)
clearColor = MTLClearColor(
red: Double(background.red * background.alpha),
diff --git a/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift b/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift
index acdc07d9..f33785d6 100644
--- a/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift
+++ b/apple/GhostteaKit/Sources/GhostteaTerminal/RetainedTRF1State.swift
@@ -171,7 +171,10 @@ struct RetainedTRF1State: Equatable, Sendable {
decodeTRF1AccessibilityRows) ?? []
let nextCursor = try decodeTRF1CursorState(cursorSection)
let glyphs = try sections[.glyphDefinitions].map(decodeTRF1GlyphDefinitions) ?? []
- let styles = try sections[.styleDefinitions].map(decodeTRF1StyleDefinitions) ?? []
+ let styles =
+ try sections[.styleDefinitions].map {
+ try decodeTRF1StyleDefinitions($0, palette: sections[.stylePalette])
+ } ?? []
let nextScrollbar = try sections[.scrollbarState].map(decodeTRF1ScrollbarState)
let nextSelection = try sections[.selectionSpans].map(decodeTRF1SelectionState)
let clipboardWrites = try clipboardSections.map(decodeTRF1ClipboardWrite)
diff --git a/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift b/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift
index 50e5164d..e2be2d79 100644
--- a/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift
+++ b/apple/GhostteaKit/Tests/GhostteaAppearanceTests/GhostteaAppearanceTests.swift
@@ -51,6 +51,36 @@ import Testing
#expect(!patched.replacingOccurrences(of: "\r\n", with: "").contains("\n"))
}
+@Test func appearanceBlockWritesReadabilitySettingsLikeTheDesktop() throws {
+ let defaults = try GhostteaConfigurationDraft.appearanceBlock(GhostteaAppearanceSelection())
+ #expect(defaults.contains("\nghosttea-light-adaptation = auto\n"))
+ #expect(defaults.contains("\nminimum-contrast = 1\n"))
+ #expect(
+ defaults.hasSuffix(
+ "minimum-contrast = 1\n" + GhostteaConfigurationDraft.appearanceBlockEnd))
+
+ for (ratio, written) in [(3.0, "3"), (4.5, "4.5"), (7.0, "7"), (2.25, "2.25")] {
+ let block = try GhostteaConfigurationDraft.appearanceBlock(
+ GhostteaAppearanceSelection(lightAdaptation: .off, minimumContrast: ratio))
+ #expect(block.contains("\nghosttea-light-adaptation = off\n"))
+ #expect(block.contains("\nminimum-contrast = \(written)\n"))
+ }
+ #expect(GhostteaAppearanceSelection.minimumContrastChoices == [1, 3, 4.5, 7])
+
+ for invalid in [0.5, 22, .nan, .infinity] {
+ #expect(throws: GhostteaConfigDraftError.invalidTheme) {
+ try GhostteaConfigurationDraft.appearanceBlock(
+ GhostteaAppearanceSelection(minimumContrast: invalid))
+ }
+ }
+}
+
+@Test func appearanceSelectionDefaultsReadabilityForConfigsWithoutIt() throws {
+ let selection = GhostteaAppearanceSelection(config: try testConfiguration())
+ #expect(selection.lightAdaptation == .auto)
+ #expect(selection.minimumContrast == 1)
+}
+
@Test func appearancePatchRejectsAmbiguousManagedMarkers() {
#expect(throws: GhostteaConfigDraftError.self) {
try GhostteaConfigurationDraft.patchAppearance(
diff --git a/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift b/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift
index c7e14009..dda1319e 100644
--- a/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift
+++ b/apple/GhostteaKit/Tests/GhostteaCoreTests/GhostteaConfigurationTests.swift
@@ -89,6 +89,8 @@ import Testing
palette: [GhostteaPaletteConfigEntry(index: 2, color: [19, 20, 21])],
backgroundOpacity: 0.5,
backgroundOpacityCells: true,
+ minimumContrast: 4.5,
+ lightAdaptation: .off,
fontSize: 13,
fontFamilies: [],
paddingX: [2, 2],
@@ -101,7 +103,7 @@ import Testing
JSONSerialization.jsonObject(with: JSONEncoder().encode(current)) as? [String: Any])
for key in [
"cursorText", "palette", "backgroundOpacity", "backgroundOpacityCells", "shaderEffects",
- "customShaderAnimation",
+ "customShaderAnimation", "minimumContrast", "lightAdaptation",
] {
object[key] = nil
}
@@ -115,9 +117,48 @@ import Testing
#expect(!decoded.backgroundOpacityCells)
#expect(decoded.shaderEffects.isEmpty)
#expect(!decoded.customShaderAnimation)
+ #expect(decoded.minimumContrast == 1)
+ #expect(decoded.lightAdaptation == .auto)
#expect(decoded.isValid)
}
+@Test func presentationRoundTripsReadabilitySettingsWithTheDesktopSpelling() throws {
+ let presentation = GhostteaTerminalPresentationConfig(
+ schemaVersion: 1,
+ revision: "readability",
+ foreground: [1, 2, 3],
+ background: [4, 5, 6],
+ cursor: [7, 8, 9],
+ selectionBackground: [13, 14, 15],
+ selectionForeground: [16, 17, 18],
+ minimumContrast: 4.5,
+ lightAdaptation: .off,
+ fontSize: 13,
+ fontFamilies: [],
+ paddingX: [2, 2],
+ paddingY: [2, 2],
+ postProcess: .none,
+ customShaderCount: 0)
+ let object = try #require(
+ JSONSerialization.jsonObject(with: JSONEncoder().encode(presentation)) as? [String: Any])
+ #expect(object["minimumContrast"] as? Double == 4.5)
+ #expect(object["lightAdaptation"] as? String == "off")
+ let decoded = try JSONDecoder().decode(
+ GhostteaTerminalPresentationConfig.self,
+ from: JSONSerialization.data(withJSONObject: object))
+ #expect(decoded == presentation)
+ #expect(
+ throws: DecodingError.self,
+ "Unknown modes are rejected like the Rust enum"
+ ) {
+ var invalid = object
+ invalid["lightAdaptation"] = "sometimes"
+ _ = try JSONDecoder().decode(
+ GhostteaTerminalPresentationConfig.self,
+ from: JSONSerialization.data(withJSONObject: invalid))
+ }
+}
+
@Test func configurationStoreValidatesSavesAndRejectsStaleRevisions() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ghosttea-config-store-\(UUID().uuidString)", isDirectory: true)
diff --git a/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift b/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift
new file mode 100644
index 00000000..028ddfa8
--- /dev/null
+++ b/apple/GhostteaKit/Tests/GhostteaFrameTests/GhostteaLightAdaptationTests.swift
@@ -0,0 +1,314 @@
+import Foundation
+import GhostteaCore
+import Metal
+import Testing
+
+@testable import GhostteaFrame
+@testable import GhostteaTerminal
+
+// Ports `packages/ghosttea-react/src/renderers/light-adaptation.test.ts`: the
+// same inputs and tolerances hold the Metal and WebGPU remaps in step.
+
+private func rgb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> GhostteaLightRGB {
+ GhostteaLightRGB(red, green, blue)
+}
+
+private func rgba(_ color: GhostteaLightRGB, alpha: Float = 1) -> GhostteaMetalColor {
+ color.metalColor.withAlpha(alpha)
+}
+
+private func bytes(_ color: GhostteaMetalColor) -> [Int] {
+ [color.red, color.green, color.blue].map { Int(($0 * 255).rounded()) }
+}
+
+private func expectNear(
+ _ actual: GhostteaMetalColor,
+ _ expected: GhostteaLightRGB,
+ tolerance: Int = 6,
+ sourceLocation: SourceLocation = #_sourceLocation
+) {
+ let expectedBytes = [Int(expected.red), Int(expected.green), Int(expected.blue)]
+ for (value, target) in zip(bytes(actual), expectedBytes) {
+ #expect(
+ abs(value - target) <= tolerance, "\(bytes(actual)) vs \(expectedBytes)",
+ sourceLocation: sourceLocation)
+ }
+}
+
+// GrokNight's base and GrokDay's paper, with a neutral ink so no tint shifts.
+private var grok: GhostteaLightRemap {
+ GhostteaLightRemap(
+ base: rgb(0x14, 0x14, 0x14),
+ paper: rgba(rgb(0xee, 0xee, 0xee)),
+ ink: rgba(rgb(0x26, 0x26, 0x26)))
+}
+
+@Test func lightAdaptationMapsTheBaseSurfaceOntoThePaper() {
+ expectNear(grok.surface(rgb(0x14, 0x14, 0x14)), rgb(0xee, 0xee, 0xee), tolerance: 1)
+}
+
+@Test func lightAdaptationKeepsTheGrayHierarchy() {
+ expectNear(
+ grok.ink(rgb(0xe1, 0xe1, 0xe1), sourceBackground: grok.base), rgb(0x26, 0x26, 0x26),
+ tolerance: 10)
+ expectNear(
+ grok.ink(rgb(0x73, 0x73, 0x73), sourceBackground: grok.base), rgb(0x74, 0x74, 0x74),
+ tolerance: 16)
+ expectNear(
+ grok.ink(rgb(0x33, 0x33, 0x33), sourceBackground: grok.base), rgb(0xcd, 0xcd, 0xcd),
+ tolerance: 8)
+}
+
+@Test func lightAdaptationKeepsAnAccentHue() {
+ let accent = grok.ink(rgb(0xe0, 0xaf, 0x68), sourceBackground: grok.base)
+ let channels = bytes(accent)
+ #expect(channels[0] > channels[1])
+ #expect(channels[1] > channels[2])
+ expectNear(accent, rgb(0xa2, 0x76, 0x12), tolerance: 14)
+}
+
+@Test func lightAdaptationKeepsAVisibleTintOnDarkDiffRows() {
+ let claude = GhostteaLightRemap(
+ base: rgb(0x1e, 0x1e, 0x2e),
+ paper: rgba(rgb(0xef, 0xf1, 0xf5)),
+ ink: rgba(rgb(0x4c, 0x4f, 0x69)))
+ let channels = bytes(claude.surface(rgb(0x3d, 0x01, 0x00)))
+ #expect(channels[0] > 240)
+ #expect(channels[0] - min(channels[1], channels[2]) > 20)
+}
+
+@Test func lightAdaptationKeepsLegibleThemeANSIColors() {
+ let red = rgb(0xd2, 0x0f, 0x39) // Catppuccin Latte red, designed for light paper
+ let latte = GhostteaLightRemap(
+ base: rgb(0x1e, 0x1e, 0x2e),
+ paper: rgba(rgb(0xef, 0xf1, 0xf5)),
+ ink: rgba(rgb(0x4c, 0x4f, 0x69)))
+ #expect(
+ bytes(latte.ink(red, sourceBackground: latte.base, paletteIndex: 1)) == [0xd2, 0x0f, 0x39])
+ // The same RGB as truecolor is an application color and is remapped.
+ #expect(bytes(latte.ink(red, sourceBackground: latte.base)) != [0xd2, 0x0f, 0x39])
+ // Neutral entries describe a role, not a hue: "white" text still darkens.
+ let white = bytes(latte.ink(rgb(0xbc, 0xc0, 0xcc), sourceBackground: latte.base, paletteIndex: 7))
+ #expect(white[0] < 0x80)
+}
+
+@Test func lightAdaptationClassifiesBackgroundsLikeTheVTShim() {
+ #expect(GhostteaLightColor.isLightBackground(rgb(0xef, 0xf1, 0xf5)))
+ #expect(!GhostteaLightColor.isLightBackground(rgb(0x1e, 0x1e, 0x2e)))
+}
+
+private func contrast(_ a: GhostteaMetalColor, _ b: GhostteaMetalColor) -> Double {
+ func luminance(_ color: GhostteaMetalColor) -> Double {
+ zip([0.2126, 0.7152, 0.0722], [color.red, color.green, color.blue]).reduce(0) { sum, pair in
+ let v = Double(pair.1)
+ return sum + pair.0 * (v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4))
+ }
+ }
+ let first = luminance(a)
+ let second = luminance(b)
+ return (max(first, second) + 0.05) / (min(first, second) + 0.05)
+}
+
+private let panel = rgba(rgb(0xe6, 0xe6, 0xe6))
+
+@Test func minimumContrastLeavesTextAloneWhenOffOrAlreadyMet() {
+ let gray = rgba(rgb(0x9a, 0x9a, 0x9a))
+ #expect(GhostteaLightColor.ensureContrast(gray, background: panel, ratio: 1) == gray)
+ let ink = rgba(rgb(0x26, 0x26, 0x26))
+ #expect(GhostteaLightColor.ensureContrast(ink, background: panel, ratio: 4.5) == ink)
+}
+
+@Test func minimumContrastMovesLightnessOnlyAsFarAsNeededAndKeepsHue() {
+ let amber = rgba(rgb(0xe0, 0xaf, 0x68), alpha: 0.55)
+ let result = GhostteaLightColor.ensureContrast(amber, background: panel, ratio: 4.5)
+ #expect(contrast(result, panel) >= 4.5)
+ #expect(contrast(result, panel) < 4.7)
+ let channels = bytes(result)
+ #expect(channels[0] > channels[1])
+ #expect(channels[1] > channels[2])
+ #expect(result.alpha == 0.55)
+}
+
+@Test func minimumContrastReachesBlackOrWhiteWhenNothingLessSuffices() {
+ let gray = rgba(rgb(0x80, 0x80, 0x80))
+ #expect(bytes(GhostteaLightColor.ensureContrast(gray, background: gray, ratio: 21)) == [0, 0, 0])
+ let lifted = GhostteaLightColor.ensureContrast(
+ rgba(rgb(0x30, 0x30, 0x30)), background: rgba(rgb(0x20, 0x20, 0x20)), ratio: 7)
+ #expect(bytes(lifted)[0] > 0x80)
+}
+
+private let paintedStyle = TRF1StyleDefinition(
+ id: 7, bold: false, italic: false, faint: false, inverse: false, invisible: false,
+ strikethrough: false, underline: false, foreground: nil,
+ background: TRF1RGB(red: 0x14, green: 0x14, blue: 0x14))
+
+private func lightTheme(adaptation: Bool = true) -> GhostteaMetalTheme {
+ var theme = GhostteaMetalTheme()
+ theme.background = rgba(rgb(0xef, 0xf1, 0xf5))
+ theme.foreground = rgba(rgb(0x4c, 0x4f, 0x69))
+ theme.lightAdaptation = adaptation
+ return theme
+}
+
+private func paintedBase(
+ _ theme: GhostteaMetalTheme, paintedRows: Int, engaged: Bool
+) -> GhostteaLightRGB? {
+ let painted = [TRF1StyleRun(styleID: 7, cellStart: 0, cellSpan: 10)]
+ return GhostteaLightColor.darkPaintedBase(
+ styleRows: (0..<10).map { $0 < paintedRows ? painted : [] },
+ rowCount: 10,
+ columns: 10,
+ styleDefinitions: [7: paintedStyle],
+ theme: theme,
+ engaged: engaged)
+}
+
+@Test func lightAdaptationEngagesForADarkPaintedPaneUnderALightTheme() {
+ #expect(paintedBase(lightTheme(), paintedRows: 10, engaged: false) == rgb(0x14, 0x14, 0x14))
+}
+
+@Test func lightAdaptationStaysOffForDarkThemesOptOutsAndThemeFollowingApps() {
+ var dark = lightTheme()
+ dark.background = rgba(rgb(0x1e, 0x1e, 0x2e))
+ #expect(paintedBase(dark, paintedRows: 10, engaged: false) == nil)
+ #expect(paintedBase(lightTheme(adaptation: false), paintedRows: 10, engaged: false) == nil)
+ #expect(paintedBase(lightTheme(), paintedRows: 1, engaged: false) == nil)
+}
+
+@Test func lightAdaptationUsesHysteresisSoPartialScrollsDoNotFlicker() {
+ #expect(paintedBase(lightTheme(), paintedRows: 5, engaged: false) == nil)
+ #expect(paintedBase(lightTheme(), paintedRows: 5, engaged: true) == rgb(0x14, 0x14, 0x14))
+ #expect(paintedBase(lightTheme(), paintedRows: 3, engaged: true) == nil)
+}
+
+@Test func graphicCellsFollowDesktopCellWidths() {
+ #expect(GhostteaMetalGraphicCells.cellWidth("界") == 2)
+ #expect(GhostteaMetalGraphicCells.cellWidth("🙂") == 2)
+ #expect(GhostteaMetalGraphicCells.cellWidth("─") == 1)
+ #expect(GhostteaMetalGraphicCells.cellWidth("a") == 1)
+}
+
+private func pixel(_ texture: any MTLTexture, x: Int, y: Int) -> [Int] {
+ var value = [UInt8](repeating: 0, count: 4)
+ value.withUnsafeMutableBytes { bytes in
+ texture.getBytes(
+ bytes.baseAddress!,
+ bytesPerRow: 4,
+ from: MTLRegionMake2D(x, y, 1, 1),
+ mipmapLevel: 0)
+ }
+ return value.map(Int.init)
+}
+
+@Test func metalRendererRestylesADarkPaintedPaneUnderALightTheme() async throws {
+ let runtime = try GhostteaRuntime()
+ let terminal = try GhostteaTerminal(
+ runtime: runtime,
+ configuration: .init(sessionHandle: 211, columns: 40, rows: 6))
+ // Paint every cell #141414 the way Grok's default theme does, then write
+ // light-gray text over it.
+ let paint = "\u{1b}[48;2;20;20;20m\u{1b}[2J\u{1b}[H\u{1b}[38;2;225;225;225mGrok night\r\n"
+ let update = try await terminal.feed(Data(paint.utf8), render: .full)
+ var state = RetainedTRF1State()
+ _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload))
+ #expect(
+ state.styleDefinitions.values.contains {
+ $0.background == TRF1RGB(red: 20, green: 20, blue: 20)
+ })
+
+ let metal = try GhostteaMetalRuntime()
+ let renderer = try GhostteaMetalRenderer(runtime: metal, alphaAtlasSize: 512, colorAtlasSize: 512)
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm, width: 240, height: 80, mipmapped: false)
+ descriptor.storageMode = .shared
+ descriptor.usage = [.renderTarget]
+ let target = try #require(metal.device.makeTexture(descriptor: descriptor))
+ // A blank cell on the third row, clear of the text and the cursor.
+ let probe = (
+ x: Int(GhostteaMetalRenderer.originX + 20 * renderer.cellWidth),
+ y: Int(GhostteaMetalRenderer.originY + 2.5 * renderer.lineHeight)
+ )
+
+ var theme = lightTheme(adaptation: false)
+ _ = try renderer.render(state: state, target: target, theme: theme)
+ let asPainted = pixel(target, x: probe.x, y: probe.y)
+ #expect(asPainted[0] < 0x30 && asPainted[1] < 0x30 && asPainted[2] < 0x30, "\(asPainted)")
+
+ // Enabling adaptation on the same renderer must invalidate cached geometry.
+ theme.lightAdaptation = true
+ _ = try renderer.render(state: state, target: target, theme: theme)
+ let adapted = pixel(target, x: probe.x, y: probe.y)
+ // The app's base surface lands on the theme paper.
+ #expect(
+ abs(adapted[0] - 0xef) <= 2 && abs(adapted[1] - 0xf1) <= 2 && abs(adapted[2] - 0xf5) <= 2,
+ "\(adapted)")
+
+ theme.lightAdaptation = false
+ _ = try renderer.render(state: state, target: target, theme: theme)
+ #expect(pixel(target, x: probe.x, y: probe.y) == asPainted)
+}
+
+@Test func metalRendererAppliesMinimumContrastToTextOnly() async throws {
+ let runtime = try GhostteaRuntime()
+ let terminal = try GhostteaTerminal(
+ runtime: runtime,
+ configuration: .init(sessionHandle: 212, columns: 40, rows: 4))
+ // Pale gray text and a block element in the same pale gray on a light theme.
+ let text = "\u{1b}[38;2;200;200;200mpale text \u{2588}\u{2588}\r\n"
+ let update = try await terminal.feed(Data(text.utf8), render: .full)
+ var state = RetainedTRF1State()
+ _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload))
+
+ let metal = try GhostteaMetalRuntime()
+ let renderer = try GhostteaMetalRenderer(runtime: metal, alphaAtlasSize: 512, colorAtlasSize: 512)
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm, width: 240, height: 40, mipmapped: false)
+ descriptor.storageMode = .shared
+ descriptor.usage = [.renderTarget]
+ let target = try #require(metal.device.makeTexture(descriptor: descriptor))
+ let block = (
+ x: Int(GhostteaMetalRenderer.originX + 10.5 * renderer.cellWidth),
+ y: Int(GhostteaMetalRenderer.originY + 0.5 * renderer.lineHeight)
+ )
+
+ var theme = lightTheme(adaptation: false)
+ let plain = try renderer.render(state: state, width: 240, height: 40, theme: theme)
+ _ = try renderer.render(state: state, target: target, theme: theme)
+ let plainBlock = pixel(target, x: block.x, y: block.y)
+ #expect(abs(plainBlock[0] - 200) <= 2, "\(plainBlock)")
+
+ theme.minimumContrast = 4.5
+ let contrasted = try renderer.render(state: state, width: 240, height: 40, theme: theme)
+ // Darker text lowers the mean; the cached geometry from the first render is not reused.
+ #expect(contrasted.pixelHash != plain.pixelHash)
+ #expect(contrasted.visualFingerprint.meanRed < plain.visualFingerprint.meanRed)
+ // Block elements are graphics and keep the raw foreground, as in Ghostty.
+ _ = try renderer.render(state: state, target: target, theme: theme)
+ #expect(pixel(target, x: block.x, y: block.y) == plainBlock)
+
+ theme.minimumContrast = 1
+ #expect(
+ try renderer.render(state: state, width: 240, height: 40, theme: theme).pixelHash
+ == plain.pixelHash)
+}
+
+@Test func productionFramesCarryPaletteProvenanceBesideTheRGB() async throws {
+ let terminal = try GhostteaTerminal(
+ runtime: try GhostteaRuntime(),
+ configuration: .init(sessionHandle: 212, columns: 20, rows: 2))
+ // ANSI red and the same RGB as truecolor must reach the renderer as two
+ // styles, and only the palette one may pass through light adaptation.
+ let probe = try await terminal.feed(Data("\u{1b}[31mA".utf8), render: .full)
+ var probeState = RetainedTRF1State()
+ _ = try probeState.apply(
+ try #require(probe.effects.first { $0.kind == .frameReady }?.payload))
+ let red = try #require(probeState.styleDefinitions.values.first { $0.foregroundPalette == 1 })
+ let rgb = try #require(red.foreground)
+ let truecolor = "\u{1b}[38;2;\(rgb.red);\(rgb.green);\(rgb.blue)mB"
+ let update = try await terminal.feed(Data(truecolor.utf8), render: .full)
+ var state = RetainedTRF1State()
+ _ = try state.apply(try #require(update.effects.first { $0.kind == .frameReady }?.payload))
+ let sameRGB = state.styleDefinitions.values.filter { $0.foreground == rgb }
+ #expect(sameRGB.count == 2)
+ #expect(Set(sameRGB.map(\.foregroundPalette)) == [1, nil])
+}
diff --git a/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift b/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift
index 40473e26..86328edf 100644
--- a/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift
+++ b/apple/GhostteaKit/Tests/GhostteaFrameTests/TRF1DecoderTests.swift
@@ -102,6 +102,8 @@ private func exerciseEveryTRF1Decoder(_ data: Data) {
_ = try? decodeTRF1GlyphDefinitions(section)
case .styleDefinitions:
_ = try? decodeTRF1StyleDefinitions(section)
+ case .stylePalette:
+ _ = try? decodeTRF1StylePalette(section)
case .rowReplacements:
_ = try? decodeTRF1RowReplacements(section)
case .cursorState:
@@ -554,3 +556,126 @@ private func exerciseEveryTRF1Decoder(_ data: Data) {
#expect(Set(state.glyphDefinitions.keys) == Set(replacementGlyphs.map(\.id)))
#expect(state.rows[0].text == "x")
}
+
+private func styleSection(
+ _ styles: [(id: UInt32, foreground: Bool, background: Bool)]
+) -> TRF1Section {
+ var bytes = Data(repeating: 0, count: 4 + styles.count * 16)
+ writeUInt32(UInt32(styles.count), to: &bytes, at: 0)
+ for (index, style) in styles.enumerated() {
+ let offset = 4 + index * 16
+ writeUInt32(style.id, to: &bytes, at: offset)
+ bytes[offset + 6] = style.foreground ? 1 : 0
+ bytes[offset + 7] = style.background ? 1 : 0
+ bytes[offset + 8] = 0xcc
+ bytes[offset + 11] = 0x81
+ }
+ return TRF1Section(
+ kind: .styleDefinitions, flags: 0, itemCount: UInt32(styles.count), bytes: bytes)
+}
+
+private func paletteSection(
+ _ entries: [(id: UInt32, flags: UInt8, foreground: UInt8, background: UInt8)],
+ itemCount: UInt32? = nil
+) -> TRF1Section {
+ var bytes = Data(repeating: 0, count: 4 + entries.count * 8)
+ writeUInt32(UInt32(entries.count), to: &bytes, at: 0)
+ for (index, entry) in entries.enumerated() {
+ let offset = 4 + index * 8
+ writeUInt32(entry.id, to: &bytes, at: offset)
+ bytes[offset + 4] = entry.flags
+ bytes[offset + 5] = entry.foreground
+ bytes[offset + 6] = entry.background
+ }
+ return TRF1Section(
+ kind: .stylePalette, flags: 0, itemCount: itemCount ?? UInt32(entries.count), bytes: bytes)
+}
+
+@Test func decodesStylePaletteProvenanceOnlyForExplicitColors() throws {
+ let styles = styleSection([(1, true, false), (2, true, true), (3, false, false)])
+ let palette = paletteSection([(1, 0b11, 9, 4), (2, 0b10, 0, 12)])
+ #expect(
+ try decodeTRF1StylePalette(palette) == [
+ TRF1StylePaletteEntry(styleID: 1, foreground: 9, background: 4),
+ TRF1StylePaletteEntry(styleID: 2, foreground: nil, background: 12),
+ ])
+
+ let decoded = try decodeTRF1StyleDefinitions(styles, palette: palette)
+ // Style 1 has no explicit background, so its background index is dropped.
+ #expect(decoded[0].foregroundPalette == 9)
+ #expect(decoded[0].backgroundPalette == nil)
+ #expect(decoded[1].foregroundPalette == nil)
+ #expect(decoded[1].backgroundPalette == 12)
+ #expect(decoded[2].foregroundPalette == nil && decoded[2].backgroundPalette == nil)
+ // RGB stays authoritative: decoding without the section differs only in provenance.
+ let plain = try decodeTRF1StyleDefinitions(styles)
+ #expect(plain.map(\.foreground) == decoded.map(\.foreground))
+ #expect(plain.allSatisfy { $0.foregroundPalette == nil && $0.backgroundPalette == nil })
+}
+
+@Test func rejectsMalformedStylePaletteSections() {
+ for flags: UInt8 in [0b100, 0x80] {
+ #expect(throws: TRF1DecodingError("invalid style palette flags")) {
+ try decodeTRF1StylePalette(paletteSection([(1, flags, 1, 1)]))
+ }
+ }
+ #expect(throws: TRF1DecodingError("style palette count mismatch")) {
+ try decodeTRF1StylePalette(paletteSection([(1, 1, 1, 0)], itemCount: 2))
+ }
+ var truncated = paletteSection([(1, 1, 1, 0)]).bytes
+ truncated.removeLast()
+ #expect(throws: TRF1DecodingError.self) {
+ try decodeTRF1StylePalette(
+ TRF1Section(kind: .stylePalette, flags: 0, itemCount: 1, bytes: truncated))
+ }
+ #expect(throws: TRF1DecodingError("wrong style palette section kind")) {
+ try decodeTRF1StylePalette(
+ TRF1Section(kind: .styleDefinitions, flags: 0, itemCount: 0, bytes: Data(count: 4)))
+ }
+ // A malformed palette fails the style decode that consumes it.
+ #expect(throws: TRF1DecodingError("invalid style palette flags")) {
+ try decodeTRF1StyleDefinitions(
+ styleSection([(1, true, false)]), palette: paletteSection([(1, 0b1000, 1, 0)]))
+ }
+}
+
+/// The shared golden frame produced by the Rust core and decoded by the
+/// TypeScript package, so the Swift decoder is held to the same bytes.
+private func ansiBaselineFrame() throws -> Data {
+ let fixture = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .appendingPathComponent("../../../../native/ghosttea/fixtures/phase1/ansi-baseline.json")
+ .standardizedFileURL
+ let object = try JSONSerialization.jsonObject(with: Data(contentsOf: fixture))
+ let hex = try #require((object as? [String: Any])?["expectedFrameHex"] as? String)
+ var bytes = Data(capacity: hex.utf8.count / 2)
+ var index = hex.startIndex
+ while index < hex.endIndex {
+ let next = hex.index(index, offsetBy: 2)
+ bytes.append(try #require(UInt8(hex[index.. {
).toEqual(["background", "custom-shader"]);
});
+ it("writes readability settings only when the renderer sends them", () => {
+ const readable = validateAppearanceUpdate({ ...update, lightAdaptation: "off", minimumContrast: 4.567 });
+ expect(readable).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.57 });
+ const block = appearanceBlock(readable);
+ expect(block).toContain("ghosttea-light-adaptation = off\nminimum-contrast = 4.57\n");
+ expect(appearanceBlock(validateAppearanceUpdate(update))).not.toMatch(/light-adaptation|minimum-contrast/u);
+ expect(() => validateAppearanceUpdate({ ...update, lightAdaptation: "maybe" })).toThrow("auto or off");
+ expect(() => validateAppearanceUpdate({ ...update, minimumContrast: 0 })).toThrow("between 1 and 21");
+
+ const renderer = { lightAdaptation: "auto", minimumContrast: 1 } as RendererConfig;
+ const customColors: ManagedAppearanceUpdate = {
+ backgroundOpacity: 1,
+ backgroundOpacityCells: false,
+ shaderEffects: [],
+ shaderAnimation: false,
+ lightAdaptation: "off",
+ minimumContrast: 4.57,
+ };
+ expect(appearanceUpdateMismatches(renderer, customColors)).toEqual(
+ expect.arrayContaining(["ghosttea-light-adaptation", "minimum-contrast"]),
+ );
+ });
+
it("refuses ambiguous or incomplete managed markers", () => {
expect(() => patchAppearanceBlock(`${APPEARANCE_BLOCK_START}\n`, appearanceBlock(update))).toThrow("malformed");
expect(() =>
diff --git a/apps/desktop/src/main/appearance-config.ts b/apps/desktop/src/main/appearance-config.ts
index fca8c4ff..a7dea0ac 100644
--- a/apps/desktop/src/main/appearance-config.ts
+++ b/apps/desktop/src/main/appearance-config.ts
@@ -15,6 +15,10 @@ export interface ManagedAppearanceUpdate {
backgroundOpacityCells: boolean;
shaderEffects: string[];
shaderAnimation: boolean;
+ /** Omitted by older renderers; the block then leaves the setting to other layers. */
+ lightAdaptation?: "auto" | "off";
+ /** WCAG ratio from 1 (off) through 21; omitted by older renderers. */
+ minimumContrast?: number;
}
export const APPEARANCE_BLOCK_START = "# >>> Ghosttea appearance (managed; edit through Settings)";
@@ -81,6 +85,18 @@ export function validateAppearanceUpdate(payload: unknown): ManagedAppearanceUpd
if (typeof update.backgroundOpacityCells !== "boolean" || typeof update.shaderAnimation !== "boolean") {
throw new Error("Invalid appearance toggle");
}
+ if (update.lightAdaptation !== undefined && update.lightAdaptation !== "auto" && update.lightAdaptation !== "off") {
+ throw new Error("Light adaptation must be auto or off");
+ }
+ if (
+ update.minimumContrast !== undefined &&
+ (typeof update.minimumContrast !== "number" ||
+ !Number.isFinite(update.minimumContrast) ||
+ update.minimumContrast < 1 ||
+ update.minimumContrast > 21)
+ ) {
+ throw new Error("Minimum contrast must be between 1 and 21");
+ }
if (
!Array.isArray(update.shaderEffects) ||
!update.shaderEffects.every((id) => typeof id === "string" && BUNDLED_SHADER_IDS.has(id))
@@ -93,9 +109,16 @@ export function validateAppearanceUpdate(payload: unknown): ManagedAppearanceUpd
backgroundOpacityCells: update.backgroundOpacityCells,
shaderEffects: [...new Set(update.shaderEffects as string[])],
shaderAnimation: update.shaderAnimation,
+ ...(update.lightAdaptation !== undefined ? { lightAdaptation: update.lightAdaptation } : {}),
+ ...(update.minimumContrast !== undefined ? { minimumContrast: contrastValue(update.minimumContrast) } : {}),
};
}
+/** Two decimals are enough for a contrast ratio and keep the config readable. */
+function contrastValue(ratio: number): number {
+ return Number(ratio.toFixed(2));
+}
+
export function appearanceBlock(update: ManagedAppearanceUpdate): string {
return [
APPEARANCE_BLOCK_START,
@@ -117,6 +140,8 @@ export function appearanceBlock(update: ManagedAppearanceUpdate): string {
"custom-shader =",
...update.shaderEffects.map((id) => `custom-shader = ${id}`),
`custom-shader-animation = ${update.shaderAnimation}`,
+ ...(update.lightAdaptation !== undefined ? [`ghosttea-light-adaptation = ${update.lightAdaptation}`] : []),
+ ...(update.minimumContrast !== undefined ? [`minimum-contrast = ${contrastValue(update.minimumContrast)}`] : []),
APPEARANCE_BLOCK_END,
].join("\n");
}
@@ -199,5 +224,14 @@ export function appearanceUpdateMismatches(renderer: RendererConfig, update: Man
if ((renderer.customShaderAnimation ?? false) !== update.shaderAnimation) {
mismatches.push("custom-shader-animation");
}
+ if (update.lightAdaptation !== undefined && (renderer.lightAdaptation ?? "auto") !== update.lightAdaptation) {
+ mismatches.push("ghosttea-light-adaptation");
+ }
+ if (
+ update.minimumContrast !== undefined &&
+ Math.abs((renderer.minimumContrast ?? 1) - contrastValue(update.minimumContrast)) > 0.0001
+ ) {
+ mismatches.push("minimum-contrast");
+ }
return mismatches;
}
diff --git a/apps/desktop/src/main/config-editor.test.ts b/apps/desktop/src/main/config-editor.test.ts
index a46b2c16..6bdb199b 100644
--- a/apps/desktop/src/main/config-editor.test.ts
+++ b/apps/desktop/src/main/config-editor.test.ts
@@ -37,6 +37,7 @@ describe("desktop config editor boundary", () => {
palette: [{ index: 2, color: [4, 5, 6] }],
backgroundOpacity: 0.73,
backgroundOpacityCells: true,
+ minimumContrast: 4.5,
fontSize: 14,
fontFamilies: ["JetBrains Mono"],
paddingX: [3, 4],
@@ -54,6 +55,7 @@ describe("desktop config editor boundary", () => {
const result = serializeSupportedGhosttyConfig(config);
expect(result).toContain("foreground = #f0f1f2\n");
expect(result).toContain("palette = 2=#040506\n");
+ expect(result).toContain("minimum-contrast = 4.5\n");
expect(result).toContain("window-padding-x = 3,4\nwindow-padding-y = 5\n");
expect(result).toContain("custom-shader = ghosttea:crt\n");
expect(result).toContain("keybind = clear\nkeybind = super+t=new_tab\n");
diff --git a/apps/desktop/src/main/config-editor.ts b/apps/desktop/src/main/config-editor.ts
index 9a7c9f74..a05d0357 100644
--- a/apps/desktop/src/main/config-editor.ts
+++ b/apps/desktop/src/main/config-editor.ts
@@ -202,6 +202,7 @@ export function serializeSupportedGhosttyConfig(config: ConfigSnapshot): string
.map(({ index, color }) => `palette = ${index}=${colorHex(color)}`),
`background-opacity = ${(renderer.backgroundOpacity ?? 1).toFixed(2)}`,
`background-opacity-cells = ${renderer.backgroundOpacityCells ?? false}`,
+ `minimum-contrast = ${renderer.minimumContrast ?? 1}`,
`scrollback-limit = ${config.terminal.scrollbackBytes}`,
"font-family =",
...renderer.fontFamilies.map((family) => `font-family = ${family.replaceAll(/[\r\n]/gu, "")}`),
diff --git a/docs/ghostty-config-compatibility.md b/docs/ghostty-config-compatibility.md
index ba2a2f5d..ac28f474 100644
--- a/docs/ghostty-config-compatibility.md
+++ b/docs/ghostty-config-compatibility.md
@@ -47,16 +47,18 @@ Electron terminal links apply `link-url` (default `true`). This controls plain
URL matching; OSC 8 targets remain clickable when it is false. The host must
provide `platform.openExternal` and use a daemon that emits TRF1 link targets.
-| Area | Daemon / Electron | Swift / Metal |
-| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Foreground, background, cursor, selection colors | Fixed colors are applied live; dynamic `cell-foreground`/`cell-background` references are diagnosed as parsed until per-cell rendering is implemented | Device config applies live to local SSH and shared sessions; protocol minor 8 keeps defaults and indexed colors semantic while application-owned truecolor remains unchanged |
-| `cursor-text`, `palette` | Applied live. Sparse palette entries override libghostty's default 256-color table; the Settings catalog writes entries 0–15 | Applied live from the device config. Shared palette cells are re-resolved locally without rebuilding text shapes or losing selection and patch continuity |
-| `background-opacity`, `background-opacity-cells` | WebGPU and Canvas2D preserve premultiplied alpha; macOS Electron windows expose it to the desktop. Explicit cell backgrounds follow the cells toggle | Applied to the Metal terminal layer for local and shared terminals; iOS does not make the app window itself transparent |
-| `scrollback-limit` | Applied to new sessions; default is Ghostty's 10,000,000 bytes | Available through `GhostteaTerminalConfiguration(config:)`; the core default is 10,000,000 bytes, while the iOS app may deliberately impose its device memory budget |
-| `keybind` | Mutations overlay the pinned 93-entry platform table; `clear`, blank reset, `unbind`, supported single-stroke actions, and `unconsumed:` are applied | Preserved in the shared API; the Swift workspace still has a smaller hand-written action router |
-| `window-padding-x/y` | Parsed, not yet used by the fixed desktop cell geometry | Applied by `GhostteaTerminalMetalView.applyConfiguration(_:)` |
-| `font-family`, `font-size` | Parsed, but runtime font metrics are still selected at process startup | `font-size` scales shaping and grid metrics and atomically reshapes a live shared replica; arbitrary `font-family` values remain diagnosed because Apple uses bundled fonts |
-| `custom-shader`, `custom-shader-animation` | Namespaced built-ins compose in declaration order through ping-pong WebGPU passes. Canvas fallback and arbitrary Ghostty GLSL are unsupported | Namespaced built-ins compose in declaration order through Metal passes and remain device-owned in shared sessions; arbitrary Ghostty shader paths are diagnosed/preserved |
+| Area | Daemon / Electron | Swift / Metal |
+| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Foreground, background, cursor, selection colors | Fixed colors are applied live; dynamic `cell-foreground`/`cell-background` references are diagnosed as parsed until per-cell rendering is implemented | Device config applies live to local SSH and shared sessions; protocol minor 8 keeps defaults and indexed colors semantic while application-owned truecolor remains unchanged |
+| `cursor-text`, `palette` | Applied live. Sparse palette entries override libghostty's default 256-color table; the Settings catalog writes entries 0–15 | Applied live from the device config. Shared palette cells are re-resolved locally without rebuilding text shapes or losing selection and patch continuity |
+| `background-opacity`, `background-opacity-cells` | WebGPU and Canvas2D preserve premultiplied alpha; macOS Electron windows expose it to the desktop. Explicit cell backgrounds follow the cells toggle | Applied to the Metal terminal layer for local and shared terminals; iOS does not make the app window itself transparent |
+| `minimum-contrast` | Applied live to text glyphs and decorations by WebGPU, preserving hue instead of snapping to black or white; box drawing, block elements, and color glyphs are exempt | Applied by the Metal renderer with the same rules |
+| `ghosttea-light-adaptation` (Ghosttea key) | `auto` (default) restyles panes an application paints dark while a light theme is active; `off` renders application colors as sent | Same remap in the Metal renderer; also carried in the remote presentation |
+| `scrollback-limit` | Applied to new sessions; default is Ghostty's 10,000,000 bytes | Available through `GhostteaTerminalConfiguration(config:)`; the core default is 10,000,000 bytes, while the iOS app may deliberately impose its device memory budget |
+| `keybind` | Mutations overlay the pinned 93-entry platform table; `clear`, blank reset, `unbind`, supported single-stroke actions, and `unconsumed:` are applied | Preserved in the shared API; the Swift workspace still has a smaller hand-written action router |
+| `window-padding-x/y` | Parsed, not yet used by the fixed desktop cell geometry | Applied by `GhostteaTerminalMetalView.applyConfiguration(_:)` |
+| `font-family`, `font-size` | Parsed, but runtime font metrics are still selected at process startup | `font-size` scales shaping and grid metrics and atomically reshapes a live shared replica; arbitrary `font-family` values remain diagnosed because Apple uses bundled fonts |
+| `custom-shader`, `custom-shader-animation` | Namespaced built-ins compose in declaration order through ping-pong WebGPU passes. Canvas fallback and arbitrary Ghostty GLSL are unsupported | Namespaced built-ins compose in declaration order through Metal passes and remain device-owned in shared sessions; arbitrary Ghostty shader paths are diagnosed/preserved |
Other recognized keys—including `theme`, shell integration, and most
window/application behavior—are currently reported
diff --git a/docs/webgpu-appearance-pipeline.md b/docs/webgpu-appearance-pipeline.md
index 9519bf79..4a21e38c 100644
--- a/docs/webgpu-appearance-pipeline.md
+++ b/docs/webgpu-appearance-pipeline.md
@@ -50,8 +50,8 @@ late overlay geometry.
Some applications paint every cell with their own truecolor background, so a
light theme never shows through (Grok's default theme paints `#141414`
-everywhere). With `lightAdaptation: "auto"`, the default from configuration,
-a pane is remapped when the theme background is light and at least 60% of its
+everywhere). With `ghosttea-light-adaptation = auto` (the default; Settings →
+Appearance → Readability, or `off` to disable), a pane is remapped when the theme background is light and at least 60% of its
cells carry an explicit dark background. It releases below 35%, so partially
painted scrolls do not flicker. Applications that follow the theme (Codex,
OpenCode, Claude Code's Auto theme) never cross that threshold.
@@ -61,9 +61,24 @@ coverage, color glyphs, and the shader stack are untouched. Surfaces mirror
their OKLab-with-toe lightness around the theme background, anchored at the
application's dominant surface. Neutral ink keeps its distance from its own
cell background, and accents land in an APCA mid-tone band with their hue
-intact. Default foreground and background stay theme-owned. Engaging,
+intact. Default foreground and background stay theme-owned, and so do the
+chromatic ANSI colors (palette 1–6 and 9–14): TRF1 section 13 tells the
+renderer which palette entry a style color came from, and the light theme's own
+entry is kept unless the remapped surface beneath leaves it less legible than
+on the theme background (capped at APCA Lc 45). Neutral entries (0, 7, 8, 15)
+describe a role rather than a hue and remap like truecolor. Engaging,
re-keying, or releasing invalidates the persistent scene and geometry cache.
+## Minimum contrast
+
+Ghostty's `minimum-contrast` (WCAG ratio 1–21, 1 is off) applies to text
+glyphs and their underline and strikethrough, measured against the cell
+background composited over the theme background, after light adaptation.
+Ghostty snaps failing text to black or white; Ghosttea keeps hue and chroma and
+moves OKLab lightness toward whichever extreme contrasts more, only as far as
+the ratio needs. Box drawing, block elements, and color glyphs are exempt, as
+in Ghostty.
+
Applications that switch themes themselves need the terminal to say which
scheme it is. ghosttead answers `CSI ? 996 n` from the default background's
luminance, sends `CSI ? 997 ; 1|2 n` when a theme change flips it and mode
diff --git a/native/ghosttea/crates/ghosttea-config/src/lib.rs b/native/ghosttea/crates/ghosttea-config/src/lib.rs
index d1752cc9..c889c897 100644
--- a/native/ghosttea/crates/ghosttea-config/src/lib.rs
+++ b/native/ghosttea/crates/ghosttea-config/src/lib.rs
@@ -31,6 +31,9 @@ pub const GHOSTTEA_BETTER_CRT_SHADER: &str = "ghosttea:better-crt";
pub const GHOSTTEA_CRT_SHADER: &str = "ghosttea:crt";
pub const GHOSTTEA_VHS_SHADER: &str = "ghosttea:vhs";
pub const GHOSTTEA_SPARKS_SHADER: &str = "ghosttea:sparks-from-fire";
+/// Ghosttea-only keys accepted beside the pinned Ghostty schema. The prefix
+/// keeps them clear of future upstream keys.
+pub const GHOSTTEA_EXTENSION_KEYS: &[&str] = &["ghosttea-light-adaptation"];
pub const CONFIG_DOCUMENT_SCHEMA_VERSION: u32 = 1;
/// Keeps a raw document plus worst-case JSON escaping below the 1 MiB control
/// packet quota. Ghostty configuration should reference large assets by path.
@@ -132,6 +135,8 @@ impl ConfigSnapshot {
palette: self.renderer.palette.clone(),
background_opacity: self.renderer.background_opacity,
background_opacity_cells: self.renderer.background_opacity_cells,
+ minimum_contrast: self.renderer.minimum_contrast,
+ light_adaptation: self.renderer.light_adaptation,
font_size: self.renderer.font_size,
font_families: self.renderer.font_families.clone(),
padding_x: self.renderer.padding_x,
@@ -249,6 +254,11 @@ pub struct RendererConfig {
pub background_opacity: f32,
#[serde(default)]
pub background_opacity_cells: bool,
+ /// WCAG contrast ratio text keeps against its cell background (1 = off).
+ #[serde(default = "default_minimum_contrast")]
+ pub minimum_contrast: f32,
+ #[serde(default)]
+ pub light_adaptation: LightAdaptation,
pub font_size: f32,
pub font_families: Vec,
pub padding_x: [f32; 2],
@@ -283,6 +293,11 @@ pub struct TerminalPresentationConfig {
pub background_opacity: f32,
#[serde(default)]
pub background_opacity_cells: bool,
+ /// WCAG contrast ratio text keeps against its cell background (1 = off).
+ #[serde(default = "default_minimum_contrast")]
+ pub minimum_contrast: f32,
+ #[serde(default)]
+ pub light_adaptation: LightAdaptation,
pub font_size: f32,
pub font_families: Vec,
pub padding_x: [f32; 2],
@@ -315,6 +330,10 @@ struct TerminalPresentationConfigWire {
background_opacity: f32,
#[serde(default)]
background_opacity_cells: bool,
+ #[serde(default = "default_minimum_contrast")]
+ minimum_contrast: f32,
+ #[serde(default)]
+ light_adaptation: LightAdaptation,
font_size: f32,
font_families: Vec,
padding_x: [f32; 2],
@@ -345,6 +364,8 @@ impl<'de> Deserialize<'de> for TerminalPresentationConfig {
palette: wire.palette,
background_opacity: wire.background_opacity,
background_opacity_cells: wire.background_opacity_cells,
+ minimum_contrast: wire.minimum_contrast,
+ light_adaptation: wire.light_adaptation,
font_size: wire.font_size,
font_families: wire.font_families,
padding_x: wire.padding_x,
@@ -357,6 +378,16 @@ impl<'de> Deserialize<'de> for TerminalPresentationConfig {
}
}
+/// Whether a light theme may restyle applications that paint their own dark
+/// backgrounds (`ghosttea-light-adaptation`).
+#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum LightAdaptation {
+ #[default]
+ Auto,
+ Off,
+}
+
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RendererPostProcess {
@@ -369,6 +400,10 @@ const fn default_background_opacity() -> f32 {
1.0
}
+const fn default_minimum_contrast() -> f32 {
+ 1.0
+}
+
const fn default_cursor_text() -> [u8; 3] {
DEFAULT_BACKGROUND
}
@@ -517,6 +552,8 @@ impl Default for ConfigSnapshot {
palette: Vec::new(),
background_opacity: 1.0,
background_opacity_cells: false,
+ minimum_contrast: 1.0,
+ light_adaptation: LightAdaptation::Auto,
font_size: platform_default_font_size(),
font_families: Vec::new(),
padding_x: [2.0, 2.0],
@@ -1311,7 +1348,9 @@ fn project(mut state: LoadState) -> ConfigSnapshot {
let mut clear_keybindings = false;
for setting in state.settings {
- if !known.contains(setting.key.as_str()) {
+ if !known.contains(setting.key.as_str())
+ && !GHOSTTEA_EXTENSION_KEYS.contains(&setting.key.as_str())
+ {
state.diagnostics.push(diagnostic_at(
DiagnosticSeverity::Warning,
"unknown-key",
@@ -1459,6 +1498,42 @@ fn project(mut state: LoadState) -> ConfigSnapshot {
)),
}
}
+ for setting in scalars
+ .iter()
+ .filter(|setting| setting.key == "minimum-contrast")
+ {
+ if setting.value.is_empty() {
+ snapshot.renderer.minimum_contrast = 1.0;
+ continue;
+ }
+ // Ghostty clamps rather than rejects out-of-range ratios.
+ match setting.value.parse::() {
+ Ok(value) if value.is_finite() => {
+ snapshot.renderer.minimum_contrast = value.clamp(1.0, 21.0);
+ }
+ _ => state.diagnostics.push(diagnostic_at(
+ DiagnosticSeverity::Error,
+ "invalid-value",
+ "`minimum-contrast` must be a contrast ratio from 1 through 21".to_owned(),
+ setting,
+ )),
+ }
+ }
+ for setting in scalars
+ .iter()
+ .filter(|setting| setting.key == "ghosttea-light-adaptation")
+ {
+ match setting.value.trim() {
+ "" | "auto" => snapshot.renderer.light_adaptation = LightAdaptation::Auto,
+ "off" => snapshot.renderer.light_adaptation = LightAdaptation::Off,
+ _ => state.diagnostics.push(diagnostic_at(
+ DiagnosticSeverity::Error,
+ "invalid-value",
+ "`ghosttea-light-adaptation` must be `auto` or `off`".to_owned(),
+ setting,
+ )),
+ }
+ }
for setting in scalars.iter().filter(|setting| setting.key == "link-url") {
if setting.value.is_empty() {
snapshot.renderer.link_url = true;
@@ -1953,6 +2028,8 @@ fn support_for_key(key: &str) -> ConfigSupport {
| "background"
| "background-opacity"
| "background-opacity-cells"
+ | "minimum-contrast"
+ | "ghosttea-light-adaptation"
| "foreground"
| "cursor-color"
| "cursor-text"
@@ -3059,4 +3136,50 @@ mod tests {
.link_url
);
}
+ #[test]
+ fn minimum_contrast_and_light_adaptation_apply_and_reach_remote_views() {
+ let temporary = TempDir::new().unwrap();
+ let config = temporary.path().join("config");
+ write(
+ &config,
+ "minimum-contrast = 4.5\nghosttea-light-adaptation = off\n",
+ );
+ let snapshot = load_config(&ConfigLoadOptions::explicit(&config));
+ assert_eq!(snapshot.renderer.minimum_contrast, 4.5);
+ assert_eq!(snapshot.renderer.light_adaptation, LightAdaptation::Off);
+ assert!(
+ snapshot.diagnostics.is_empty(),
+ "{:?}",
+ snapshot.diagnostics
+ );
+ for key in ["minimum-contrast", "ghosttea-light-adaptation"] {
+ assert!(
+ snapshot
+ .configured_keys
+ .iter()
+ .any(|configured| configured.key == key
+ && configured.support == ConfigSupport::Applied),
+ "{key}"
+ );
+ }
+ let presentation = snapshot.terminal_presentation();
+ assert_eq!(presentation.minimum_contrast, 4.5);
+ assert_eq!(presentation.light_adaptation, LightAdaptation::Off);
+
+ // Ghostty clamps the ratio; an empty value resets both keys.
+ write(&config, "minimum-contrast = 40\n");
+ let snapshot = load_config(&ConfigLoadOptions::explicit(&config));
+ assert_eq!(snapshot.renderer.minimum_contrast, 21.0);
+ assert_eq!(snapshot.renderer.light_adaptation, LightAdaptation::Auto);
+ write(
+ &config,
+ "minimum-contrast = 3\nminimum-contrast =\nghosttea-light-adaptation = sometimes\n",
+ );
+ let snapshot = load_config(&ConfigLoadOptions::explicit(&config));
+ assert_eq!(snapshot.renderer.minimum_contrast, 1.0);
+ assert!(snapshot.diagnostics.iter().any(|diagnostic| {
+ diagnostic.code == "invalid-value"
+ && diagnostic.key.as_deref() == Some("ghosttea-light-adaptation")
+ }));
+ }
}
diff --git a/native/ghosttea/crates/ghosttea-core/src/frame.rs b/native/ghosttea/crates/ghosttea-core/src/frame.rs
index 96998282..c3262dfc 100644
--- a/native/ghosttea/crates/ghosttea-core/src/frame.rs
+++ b/native/ghosttea/crates/ghosttea-core/src/frame.rs
@@ -16,6 +16,7 @@ pub const GLYPH_DEFINITIONS: u16 = 1;
pub const CLIPBOARD_WRITE: u16 = 11;
pub const SELECTION_SPANS: u16 = 5;
pub const LINK_TARGETS: u16 = 12;
+pub const STYLE_PALETTE: u16 = 13;
pub const FULL_SNAPSHOT: u16 = 1;
pub const MOUSE_TRACKING: u16 = 1 << 1;
pub const CATALOG_RESET: u16 = 1 << 2;
@@ -62,7 +63,29 @@ fn encode_glyph_definitions(definitions: &[GlyphDefinition]) -> Result<(Vec,
Ok((bytes, count))
}
-fn style_id(style: CellStyle) -> u32 {
+/// Palette entries a style's explicit colors were resolved from. Renderers use
+/// it to tell theme-designed ANSI colors from application truecolor; the RGB
+/// in the style definition stays authoritative for decoders that ignore it.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct StylePalette {
+ pub foreground: Option,
+ pub background: Option,
+}
+
+impl StylePalette {
+ fn is_empty(self) -> bool {
+ self.foreground.is_none() && self.background.is_none()
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct FrameStyle {
+ pub style: CellStyle,
+ pub palette: StylePalette,
+}
+
+fn style_id(style: FrameStyle) -> u32 {
+ let FrameStyle { style, palette } = style;
if style == CellStyle::default() {
return 0;
}
@@ -85,6 +108,22 @@ fn style_id(style: CellStyle) -> u32 {
.chain(foreground)
.chain([u8::from(style.background.is_some())])
.chain(background)
+ // Palette provenance only extends the hash when present, so ids of
+ // truecolor styles are unchanged and same-RGB sources stay distinct.
+ .chain(
+ (!palette.is_empty())
+ .then(|| {
+ [
+ 2,
+ u8::from(palette.foreground.is_some()),
+ palette.foreground.unwrap_or(0),
+ u8::from(palette.background.is_some()),
+ palette.background.unwrap_or(0),
+ ]
+ })
+ .into_iter()
+ .flatten(),
+ )
{
hash ^= byte as u32;
hash = hash.wrapping_mul(16_777_619);
@@ -92,10 +131,10 @@ fn style_id(style: CellStyle) -> u32 {
if hash == 0 { 1 } else { hash }
}
-fn encode_style_definitions(styles: impl ExactSizeIterator- ) -> Vec {
+fn encode_style_definitions(styles: &[(u32, FrameStyle)]) -> Vec {
let mut bytes = Vec::with_capacity(4 + styles.len() * 16);
bytes.extend_from_slice(&(styles.len() as u32).to_le_bytes());
- for (id, style) in styles {
+ for &(id, FrameStyle { style, .. }) in styles {
let flags = u16::from(style.bold)
| (u16::from(style.italic) << 1)
| (u16::from(style.faint) << 2)
@@ -116,15 +155,37 @@ fn encode_style_definitions(styles: impl ExactSizeIterator
- (Vec, u32) {
+ let entries = styles
+ .iter()
+ .filter(|(_, style)| !style.palette.is_empty())
+ .collect::>();
+ let mut bytes = Vec::with_capacity(4 + entries.len() * 8);
+ bytes.extend_from_slice(&(entries.len() as u32).to_le_bytes());
+ for (id, FrameStyle { palette, .. }) in &entries {
+ bytes.extend_from_slice(&id.to_le_bytes());
+ bytes.push(
+ u8::from(palette.foreground.is_some()) | (u8::from(palette.background.is_some()) << 1),
+ );
+ bytes.push(palette.foreground.unwrap_or(0));
+ bytes.push(palette.background.unwrap_or(0));
+ bytes.push(0);
+ }
+ (bytes, entries.len() as u32)
+}
+
struct PreparedRowStyles {
cell_ids: Vec,
column_ids: Vec,
}
enum PreparedFrameStyles {
- Linear(BTreeMap),
+ Linear(BTreeMap),
Indexed {
- definitions: HashMap,
+ definitions: HashMap,
rows: Vec,
},
}
@@ -137,18 +198,16 @@ impl PreparedFrameStyles {
}
}
- fn encode(&self) -> Vec {
+ fn sorted(&self) -> Vec<(u32, FrameStyle)> {
match self {
- Self::Linear(styles) => {
- encode_style_definitions(styles.iter().map(|(&id, &style)| (id, style)))
- }
+ Self::Linear(styles) => styles.iter().map(|(&id, &style)| (id, style)).collect(),
Self::Indexed { definitions, .. } => {
let mut sorted = definitions
.iter()
.map(|(&id, &style)| (id, style))
.collect::>();
sorted.sort_unstable_by_key(|(id, _)| *id);
- encode_style_definitions(sorted.into_iter())
+ sorted
}
}
}
@@ -161,6 +220,14 @@ pub(crate) trait FrameCell {
fn column(&self) -> u16;
fn span(&self) -> u16;
fn style(&self) -> CellStyle;
+ fn palette(&self) -> StylePalette;
+
+ fn frame_style(&self) -> FrameStyle {
+ FrameStyle {
+ style: self.style(),
+ palette: self.palette(),
+ }
+ }
}
impl FrameCell for TerminalCell {
@@ -175,6 +242,17 @@ impl FrameCell for TerminalCell {
fn style(&self) -> CellStyle {
self.style
}
+
+ fn palette(&self) -> StylePalette {
+ StylePalette {
+ foreground: self
+ .foreground_palette
+ .filter(|_| self.style.foreground.is_some()),
+ background: self
+ .background_palette
+ .filter(|_| self.style.background.is_some()),
+ }
+ }
}
fn append_style_run(runs: &mut Vec<(u32, u16, u16)>, cell: &C, id: u32) {
@@ -194,12 +272,12 @@ fn append_style_run(runs: &mut Vec<(u32, u16, u16)>, cell: &C, id:
fn prepare_row_styles(
cells: &[C],
shaped: &ShapedRow,
- styles: &mut HashMap,
+ styles: &mut HashMap,
) -> PreparedRowStyles {
let cell_ids = cells
.iter()
.map(|cell| {
- let style = cell.style();
+ let style = cell.frame_style();
let id = style_id(style);
styles.entry(id).or_insert(style);
id
@@ -339,7 +417,7 @@ pub(crate) fn encode_frame_text_snapshot(
.any(|row| cells[*row as usize].len() >= 8);
let prepared_styles = if indexed_styles {
let mut definitions = HashMap::new();
- definitions.insert(0, CellStyle::default());
+ definitions.insert(0, FrameStyle::default());
let rows = updated_rows
.iter()
.map(|row| {
@@ -350,17 +428,19 @@ pub(crate) fn encode_frame_text_snapshot(
PreparedFrameStyles::Indexed { definitions, rows }
} else {
let mut definitions = BTreeMap::new();
- definitions.insert(0, CellStyle::default());
+ definitions.insert(0, FrameStyle::default());
for row in updated_rows {
for cell in &cells[*row as usize] {
- let style = cell.style();
+ let style = cell.frame_style();
definitions.entry(style_id(style)).or_insert(style);
}
}
PreparedFrameStyles::Linear(definitions)
};
let (glyph_definitions, glyph_count) = encode_glyph_definitions(new_glyph_definitions)?;
- let style_definitions = prepared_styles.encode();
+ let sorted_styles = prepared_styles.sorted();
+ let style_definitions = encode_style_definitions(&sorted_styles);
+ let (style_palette, style_palette_count) = encode_style_palette(&sorted_styles);
let mut accessibility = Vec::new();
accessibility.extend_from_slice(&(updated_rows.len() as u16).to_le_bytes());
let mut replacements = Vec::new();
@@ -385,7 +465,7 @@ pub(crate) fn encode_frame_text_snapshot(
match &prepared_styles {
PreparedFrameStyles::Linear(_) => {
for cell in row_cells {
- append_style_run(&mut runs, cell, style_id(cell.style()));
+ append_style_run(&mut runs, cell, style_id(cell.frame_style()));
}
}
PreparedFrameStyles::Indexed { rows, .. } => {
@@ -405,7 +485,7 @@ pub(crate) fn encode_frame_text_snapshot(
glyph.cell_start >= cell.column()
&& glyph.cell_start < cell.column().saturating_add(cell.span())
})
- .map(|cell| style_id(cell.style()))
+ .map(|cell| style_id(cell.frame_style()))
.unwrap_or(0);
encode_glyph_instance(&mut replacements, glyph, id);
}
@@ -459,7 +539,11 @@ pub(crate) fn encode_frame_text_snapshot(
}
}
}
- let section_count = 7 + usize::from(links.is_some()) + usize::from(clipboard_payload.is_some());
+ let palette_section = style_palette_count > 0;
+ let section_count = 7
+ + usize::from(links.is_some())
+ + usize::from(clipboard_payload.is_some())
+ + usize::from(palette_section);
let glyph_offset = FRAME_HEADER_BYTES + SECTION_HEADER_BYTES * section_count;
let style_offset = glyph_offset + glyph_definitions.len();
let replacement_offset = style_offset + style_definitions.len();
@@ -491,6 +575,10 @@ pub(crate) fn encode_frame_text_snapshot(
let link_offset = packet.len();
packet.extend_from_slice(&link_payload);
+ let style_palette_offset = packet.len();
+ if palette_section {
+ packet.extend_from_slice(&style_palette);
+ }
put_u32(&mut packet, 0, FRAME_MAGIC);
put_u16(&mut packet, 4, 1);
@@ -561,6 +649,15 @@ pub(crate) fn encode_frame_text_snapshot(
put_u32(&mut packet, link_header + 8, link_payload.len() as u32);
put_u32(&mut packet, link_header + 12, links.len() as u32);
}
+ if palette_section {
+ let palette_header = 176
+ + (usize::from(clipboard_payload.is_some()) + usize::from(links.is_some()))
+ * SECTION_HEADER_BYTES;
+ put_u16(&mut packet, palette_header, STYLE_PALETTE);
+ put_u32(&mut packet, palette_header + 4, style_palette_offset as u32);
+ put_u32(&mut packet, palette_header + 8, style_palette.len() as u32);
+ put_u32(&mut packet, palette_header + 12, style_palette_count);
+ }
Ok(packet)
}
@@ -635,14 +732,14 @@ mod tests {
definitions: Vec::new(),
};
let mut definitions = HashMap::new();
- definitions.insert(0, CellStyle::default());
+ definitions.insert(0, FrameStyle::default());
let prepared = prepare_row_styles(&cells, &shaped, &mut definitions);
assert_eq!(
prepared.cell_ids,
cells
.iter()
- .map(|cell| style_id(cell.style))
+ .map(|cell| style_id(cell.frame_style()))
.collect::>()
);
for glyph in &shaped.glyphs {
@@ -652,7 +749,7 @@ mod tests {
glyph.cell_start >= cell.column
&& glyph.cell_start < cell.column.saturating_add(cell.span)
})
- .map(|cell| style_id(cell.style))
+ .map(|cell| style_id(cell.frame_style()))
.unwrap_or(0);
assert_eq!(
prepared
@@ -667,6 +764,125 @@ mod tests {
}
}
+ fn frame_sections(frame: &[u8]) -> Vec<(u16, &[u8], u32)> {
+ let count = u16::from_le_bytes([frame[60], frame[61]]) as usize;
+ (0..count)
+ .map(|index| {
+ let header = &frame[FRAME_HEADER_BYTES + index * SECTION_HEADER_BYTES..];
+ let field = |at: usize| u32::from_le_bytes(header[at..at + 4].try_into().unwrap());
+ let (offset, length) = (field(4) as usize, field(8) as usize);
+ (
+ u16::from_le_bytes([header[0], header[1]]),
+ &frame[offset..offset + length],
+ field(12),
+ )
+ })
+ .collect()
+ }
+
+ #[test]
+ fn palette_colors_carry_their_index_beside_the_resolved_rgb() {
+ let mut terminal = ghosttea_vt::GhosttyTerminalCore::new(8, 1, 0).unwrap();
+ terminal.feed(b"\x1b[31;44mA\x1b[0m");
+ let palette_red = terminal.snapshot().unwrap().cells[0][0]
+ .style
+ .foreground
+ .unwrap();
+ let [r, g, b] = palette_red;
+ terminal.feed(format!("\x1b[38;2;{r};{g};{b}mB\x1b[0m").as_bytes());
+ let snapshot = terminal.snapshot().unwrap();
+ let shaped_rows = vec![ShapedRow::default(); snapshot.rows.len()];
+ let cursor = FrameCursor {
+ x: 0,
+ y: 0,
+ visible: false,
+ style: 0,
+ blinking: false,
+ };
+ let frame = encode_text_snapshot(TextSnapshot {
+ session_handle: 1,
+ session_epoch: 1,
+ layout_epoch: 1,
+ sequence: 1,
+ revision: 1,
+ cols: snapshot.cols,
+ rows: &snapshot.rows,
+ shaped_rows: &shaped_rows,
+ cells: &snapshot.cells,
+ updated_rows: &[0],
+ full_snapshot: true,
+ catalog_reset: false,
+ mouse_tracking: false,
+ scrollbar: &snapshot.scrollbar,
+ selection: None,
+ new_glyph_definitions: &[],
+ clipboard: None,
+ cursor: &cursor,
+ links: None,
+ })
+ .unwrap();
+
+ let cells = &snapshot.cells[0];
+ let palette_id = style_id(cells[0].frame_style());
+ let truecolor_id = style_id(cells[1].frame_style());
+ assert_eq!(cells[0].style.foreground, cells[1].style.foreground);
+ assert_ne!(palette_id, truecolor_id, "same RGB from different sources");
+
+ let sections = frame_sections(&frame);
+ let (_, payload, count) = sections
+ .iter()
+ .find(|(kind, ..)| *kind == STYLE_PALETTE)
+ .copied()
+ .expect("palette section");
+ assert_eq!(count, 1);
+ let mut expected = 1_u32.to_le_bytes().to_vec();
+ expected.extend_from_slice(&palette_id.to_le_bytes());
+ expected.extend_from_slice(&[0b11, 1, 4, 0]);
+ assert_eq!(payload, expected);
+ }
+
+ #[test]
+ fn truecolor_frames_omit_the_palette_section() {
+ let mut terminal = ghosttea_vt::GhosttyTerminalCore::new(8, 1, 0).unwrap();
+ terminal.feed(b"\x1b[38;2;1;2;3mA");
+ let snapshot = terminal.snapshot().unwrap();
+ let shaped_rows = vec![ShapedRow::default(); 1];
+ let cursor = FrameCursor {
+ x: 0,
+ y: 0,
+ visible: false,
+ style: 0,
+ blinking: false,
+ };
+ let frame = encode_text_snapshot(TextSnapshot {
+ session_handle: 1,
+ session_epoch: 1,
+ layout_epoch: 1,
+ sequence: 1,
+ revision: 1,
+ cols: snapshot.cols,
+ rows: &snapshot.rows,
+ shaped_rows: &shaped_rows,
+ cells: &snapshot.cells,
+ updated_rows: &[0],
+ full_snapshot: true,
+ catalog_reset: false,
+ mouse_tracking: false,
+ scrollbar: &snapshot.scrollbar,
+ selection: None,
+ new_glyph_definitions: &[],
+ clipboard: None,
+ cursor: &cursor,
+ links: None,
+ })
+ .unwrap();
+ assert!(
+ frame_sections(&frame)
+ .iter()
+ .all(|(kind, ..)| *kind != STYLE_PALETTE)
+ );
+ }
+
#[test]
fn snapshot_has_expected_header() {
let mut engine = ghosttea_text::TextEngine::discover().unwrap();
diff --git a/native/ghosttea/crates/ghosttea-core/src/replica.rs b/native/ghosttea/crates/ghosttea-core/src/replica.rs
index 3ad92047..6a6ea0d6 100644
--- a/native/ghosttea/crates/ghosttea-core/src/replica.rs
+++ b/native/ghosttea/crates/ghosttea-core/src/replica.rs
@@ -10,7 +10,7 @@ use ghosttea_vt::{
CellStyle, TerminalPalette, TerminalScrollbar, TerminalSelection, resolved_palette,
};
-use crate::frame::{FrameCell, FrameTextSnapshot, encode_frame_text_snapshot};
+use crate::frame::{FrameCell, FrameTextSnapshot, StylePalette, encode_frame_text_snapshot};
use crate::{
FrameCursor, LogicalCellStyle, LogicalRow, LogicalTerminalPatch, LogicalTerminalSnapshot,
TerminalEffect, TerminalRuntime, TerminalUpdate, TextEnginePerformanceSnapshot,
@@ -21,6 +21,7 @@ struct ReplicaFrameCell {
column: u16,
span: u16,
style: CellStyle,
+ palette: StylePalette,
}
impl FrameCell for ReplicaFrameCell {
@@ -35,6 +36,10 @@ impl FrameCell for ReplicaFrameCell {
fn style(&self) -> CellStyle {
self.style
}
+
+ fn palette(&self) -> StylePalette {
+ self.palette
+ }
}
#[derive(Default)]
@@ -525,6 +530,16 @@ fn prepare_logical_cells(row: &LogicalRow, palette: &TerminalPalette) -> Vec {
]);
});
+ it("attaches palette provenance from the style palette section", () => {
+ const styles = new Uint8Array(4 + 2 * 16);
+ const styleView = new DataView(styles.buffer);
+ styleView.setUint32(0, 2, true);
+ styleView.setUint32(4, 7, true);
+ styleView.setUint8(10, 1);
+ styleView.setUint8(11, 1);
+ styles.set([204, 102, 102, 20, 30, 40], 12);
+ styleView.setUint32(20, 8, true);
+ styleView.setUint8(26, 1);
+ styles.set([204, 102, 102], 28);
+ const palette = new Uint8Array(4 + 8);
+ const paletteView = new DataView(palette.buffer);
+ paletteView.setUint32(0, 1, true);
+ paletteView.setUint32(4, 7, true);
+ palette.set([0b01, 1, 0, 0], 8);
+ const decoded = decodeStyleDefinitions(
+ { kind: SectionKind.StyleDefinitions, flags: 0, itemCount: 2, bytes: styles },
+ { kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette },
+ );
+ expect(decoded[0]).toMatchObject({ foreground: [204, 102, 102], foregroundPalette: 1 });
+ expect(decoded[0]).not.toHaveProperty("backgroundPalette");
+ expect(decoded[1]).not.toHaveProperty("foregroundPalette");
+ expect(decodeStylePalette({ kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette })).toEqual([
+ { styleId: 7, foreground: 1 },
+ ]);
+ palette[8] = 0b100;
+ expect(() =>
+ decodeStylePalette({ kind: SectionKind.StylePalette, flags: 0, itemCount: 1, bytes: palette }),
+ ).toThrow("invalid style palette flags");
+ });
+
it("decodes native alpha glyph definitions", () => {
const bytes = new Uint8Array(4 + 20 + 4);
const view = new DataView(bytes.buffer);
diff --git a/packages/ghosttea-frame/src/index.ts b/packages/ghosttea-frame/src/index.ts
index 4cb152de..70ab6f77 100644
--- a/packages/ghosttea-frame/src/index.ts
+++ b/packages/ghosttea-frame/src/index.ts
@@ -22,6 +22,7 @@ export enum SectionKind {
AccessibilityText = 10,
ClipboardWrite = 11,
LinkTargets = 12,
+ StylePalette = 13,
}
export interface FrameSection {
@@ -93,6 +94,16 @@ export interface StyleDefinition {
underline: boolean;
foreground?: readonly [number, number, number];
background?: readonly [number, number, number];
+ /** Palette index `foreground` was resolved from, when it came from the palette. */
+ foregroundPalette?: number;
+ /** Palette index `background` was resolved from, when it came from the palette. */
+ backgroundPalette?: number;
+}
+
+export interface StylePaletteEntry {
+ styleId: number;
+ foreground?: number;
+ background?: number;
}
export interface StyleRun {
@@ -275,7 +286,11 @@ export function decodeGlyphDefinitions(section: FrameSection): GlyphDefinition[]
return definitions;
}
-export function decodeStyleDefinitions(section: FrameSection): StyleDefinition[] {
+/**
+ * Decodes style definitions. Pass the frame's optional `StylePalette` section
+ * to attach palette provenance; the RGB colors are complete without it.
+ */
+export function decodeStyleDefinitions(section: FrameSection, paletteSection?: FrameSection): StyleDefinition[] {
assertRange(section.kind === SectionKind.StyleDefinitions, "wrong style definition section kind");
assertRange(section.bytes.byteLength >= 4, "truncated style definition header");
const view = new DataView(section.bytes.buffer, section.bytes.byteOffset, section.bytes.byteLength);
@@ -308,7 +323,38 @@ export function decodeStyleDefinitions(section: FrameSection): StyleDefinition[]
: {}),
});
}
- return styles;
+ if (!paletteSection) return styles;
+ const palette = new Map(decodeStylePalette(paletteSection).map((entry) => [entry.styleId, entry]));
+ return styles.map((style) => {
+ const entry = palette.get(style.id);
+ if (!entry) return style;
+ return {
+ ...style,
+ ...(entry.foreground !== undefined && style.foreground ? { foregroundPalette: entry.foreground } : {}),
+ ...(entry.background !== undefined && style.background ? { backgroundPalette: entry.background } : {}),
+ };
+ });
+}
+
+export function decodeStylePalette(section: FrameSection): StylePaletteEntry[] {
+ assertRange(section.kind === SectionKind.StylePalette, "wrong style palette section kind");
+ assertRange(section.bytes.byteLength >= 4, "truncated style palette header");
+ const view = new DataView(section.bytes.buffer, section.bytes.byteOffset, section.bytes.byteLength);
+ const count = view.getUint32(0, true);
+ assertRange(count === section.itemCount, "style palette count mismatch");
+ assertRange(section.bytes.byteLength === 4 + count * 8, "invalid style palette length");
+ const entries: StylePaletteEntry[] = [];
+ for (let index = 0; index < count; index += 1) {
+ const offset = 4 + index * 8;
+ const flags = view.getUint8(offset + 4);
+ assertRange((flags & ~0x3) === 0, "invalid style palette flags");
+ entries.push({
+ styleId: view.getUint32(offset, true),
+ ...(flags & 1 ? { foreground: view.getUint8(offset + 5) } : {}),
+ ...(flags & 2 ? { background: view.getUint8(offset + 6) } : {}),
+ });
+ }
+ return entries;
}
export function decodeCursorState(section: FrameSection): CursorState {
diff --git a/packages/ghosttea-protocol/src/index.test.ts b/packages/ghosttea-protocol/src/index.test.ts
index 1e272051..be7fe662 100644
--- a/packages/ghosttea-protocol/src/index.test.ts
+++ b/packages/ghosttea-protocol/src/index.test.ts
@@ -370,6 +370,20 @@ describe("isServerEvent", () => {
config: { ...config, renderer: { ...config.renderer, postProcess: "mystery" } },
}),
).toBe(false);
+ for (const renderer of [
+ { ...config.renderer, lightAdaptation: "sometimes" },
+ { ...config.renderer, minimumContrast: 0.5 },
+ { ...config.renderer, minimumContrast: Number.NaN },
+ ]) {
+ expect(isServerEvent({ requestId: 0, type: "config-changed", config: { ...config, renderer } })).toBe(false);
+ }
+ expect(
+ isServerEvent({
+ requestId: 0,
+ type: "config-changed",
+ config: { ...config, renderer: { ...config.renderer, lightAdaptation: "off", minimumContrast: 4.5 } },
+ }),
+ ).toBe(true);
expect(isServerEvent({ requestId: 4, type: "config", config: { ...config, schemaVersion: 2 } })).toBe(false);
expect(
isServerEvent({
diff --git a/packages/ghosttea-protocol/src/index.ts b/packages/ghosttea-protocol/src/index.ts
index 1e00b1ee..c73739c5 100644
--- a/packages/ghosttea-protocol/src/index.ts
+++ b/packages/ghosttea-protocol/src/index.ts
@@ -11,6 +11,8 @@ export type ConfigDiagnosticSeverity = "info" | "warning" | "error";
export type ConfigSupport = "applied" | "parsed" | "unsupported";
export type ConfigSourceKind = "ghostty-default" | "included" | "ghosttea-overlay";
export type RendererPostProcess = "none" | "better-crt";
+/** `ghosttea-light-adaptation`: may a light theme restyle apps that paint their own dark backgrounds. */
+export type LightAdaptation = "auto" | "off";
export interface ConfigCompatibility {
ghosttyVersion: string;
@@ -63,6 +65,9 @@ export interface RendererConfig {
palette?: PaletteConfigEntry[];
backgroundOpacity?: number;
backgroundOpacityCells?: boolean;
+ /** Ghostty `minimum-contrast`: WCAG ratio text keeps against its cell background; 1 is off. */
+ minimumContrast?: number;
+ lightAdaptation?: LightAdaptation;
fontSize: number;
fontFamilies: string[];
paddingX: [number, number];
@@ -906,6 +911,14 @@ export function isServerEvent(value: unknown): value is ServerEvent {
renderer.backgroundOpacity >= 0 &&
renderer.backgroundOpacity <= 1)) &&
(renderer.backgroundOpacityCells === undefined || typeof renderer.backgroundOpacityCells === "boolean") &&
+ (renderer.minimumContrast === undefined ||
+ (typeof renderer.minimumContrast === "number" &&
+ Number.isFinite(renderer.minimumContrast) &&
+ renderer.minimumContrast >= 1 &&
+ renderer.minimumContrast <= 21)) &&
+ (renderer.lightAdaptation === undefined ||
+ renderer.lightAdaptation === "auto" ||
+ renderer.lightAdaptation === "off") &&
(renderer.linkUrl === undefined || typeof renderer.linkUrl === "boolean") &&
typeof renderer.fontSize === "number" &&
Number.isFinite(renderer.fontSize) &&
diff --git a/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts b/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts
index 94fb2994..e91b040d 100644
--- a/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts
+++ b/packages/ghosttea-react/src/appearance/AppearanceSettings.test.ts
@@ -25,7 +25,15 @@ describe("Appearance settings draft", () => {
opacityCells: true,
effects: ["ghosttea:crt"],
animation: true,
+ lightAdaptation: "auto",
+ minimumContrast: 1,
});
+ expect(
+ appearanceDraftFromConfig({
+ ...config,
+ renderer: { ...config.renderer, lightAdaptation: "off", minimumContrast: 4.5 },
+ }),
+ ).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.5 });
});
it("detects a stale sibling draft without relying on object identity", () => {
@@ -33,5 +41,7 @@ describe("Appearance settings draft", () => {
expect(sameAppearanceDraft(baseline, { ...baseline, effects: [...baseline.effects] })).toBe(true);
expect(sameAppearanceDraft(baseline, { ...baseline, opacity: 0.5 })).toBe(false);
expect(sameAppearanceDraft(baseline, { ...baseline, effects: [] })).toBe(false);
+ expect(sameAppearanceDraft(baseline, { ...baseline, lightAdaptation: "off" })).toBe(false);
+ expect(sameAppearanceDraft(baseline, { ...baseline, minimumContrast: 3 })).toBe(false);
});
});
diff --git a/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx b/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx
index 6f1b4bb2..f83c03fb 100644
--- a/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx
+++ b/packages/ghosttea-react/src/appearance/AppearanceSettings.tsx
@@ -13,6 +13,8 @@ export interface AppearanceDraftState {
opacityCells: boolean;
effects: TerminalShaderEffect[];
animation: boolean;
+ lightAdaptation: "auto" | "off";
+ minimumContrast: number;
}
export function appearanceDraftFromConfig(config: ConfigSnapshot): AppearanceDraftState {
@@ -24,6 +26,8 @@ export function appearanceDraftFromConfig(config: ConfigSnapshot): AppearanceDra
config.renderer.shaderEffects?.filter(isGhostteaShaderEffect) ??
(config.renderer.postProcess === "better-crt" ? ["ghosttea:better-crt"] : []),
animation: config.renderer.customShaderAnimation ?? false,
+ lightAdaptation: config.renderer.lightAdaptation ?? "auto",
+ minimumContrast: config.renderer.minimumContrast ?? 1,
};
}
@@ -33,6 +37,8 @@ export function sameAppearanceDraft(left: AppearanceDraftState, right: Appearanc
left.opacity === right.opacity &&
left.opacityCells === right.opacityCells &&
left.animation === right.animation &&
+ left.lightAdaptation === right.lightAdaptation &&
+ left.minimumContrast === right.minimumContrast &&
left.effects.length === right.effects.length &&
left.effects.every((effect, index) => effect === right.effects[index])
);
@@ -72,11 +78,13 @@ export function AppearanceSettings({
const [opacityCells, setOpacityCells] = useState(initialAppearance.opacityCells);
const [effects, setEffects] = useState(initialAppearance.effects);
const [animation, setAnimation] = useState(initialAppearance.animation);
+ const [lightAdaptation, setLightAdaptation] = useState(initialAppearance.lightAdaptation);
+ const [minimumContrast, setMinimumContrast] = useState(initialAppearance.minimumContrast);
const [saving, setSaving] = useState(false);
const [error, setError] = useState();
const appearanceDraft = useMemo(
- () => ({ themeName, opacity, opacityCells, effects, animation }),
- [animation, effects, opacity, opacityCells, themeName],
+ () => ({ themeName, opacity, opacityCells, effects, animation, lightAdaptation, minimumContrast }),
+ [animation, effects, lightAdaptation, minimumContrast, opacity, opacityCells, themeName],
);
const appearanceDirty = !sameAppearanceDraft(appearanceDraft, appearanceBaseline);
const appearanceStale = appearanceDirty && observedConfig.revision !== appearanceBaselineRevision;
@@ -94,6 +102,8 @@ export function AppearanceSettings({
setOpacityCells(next.opacityCells);
setEffects(next.effects);
setAnimation(next.animation);
+ setLightAdaptation(next.lightAdaptation);
+ setMinimumContrast(next.minimumContrast);
setAppearanceBaseline(next);
setAppearanceBaselineRevision(nextConfig.revision);
}, []);
@@ -175,6 +185,8 @@ export function AppearanceSettings({
backgroundOpacityCells: opacityCells,
shaderEffects: effects,
shaderAnimation: animation,
+ lightAdaptation,
+ minimumContrast,
});
requestClose();
} catch (cause) {
@@ -344,6 +356,33 @@ export function AppearanceSettings({
)}
+ Readability
+
+
+
+ Text below this ratio against its background is lightened or darkened, keeping its hue. 4.5:1 is the
+ WCAG AA level for body text.
+
+
Shader stack
Effects run top-to-bottom. Animated effects repaint while this window is focused.
diff --git a/packages/ghosttea-react/src/appearance/types.ts b/packages/ghosttea-react/src/appearance/types.ts
index 404f3924..b1c942c2 100644
--- a/packages/ghosttea-react/src/appearance/types.ts
+++ b/packages/ghosttea-react/src/appearance/types.ts
@@ -21,6 +21,10 @@ export interface GhostteaAppearanceUpdate {
backgroundOpacityCells: boolean;
shaderEffects: TerminalShaderEffect[];
shaderAnimation: boolean;
+ /** `ghosttea-light-adaptation`; omitted by hosts that predate the setting. */
+ lightAdaptation?: "auto" | "off";
+ /** Ghostty `minimum-contrast` ratio (1 is off). */
+ minimumContrast?: number;
}
export interface GhostteaConfigEditorState {
diff --git a/packages/ghosttea-react/src/config.test.ts b/packages/ghosttea-react/src/config.test.ts
index 0bb73277..9bb8d945 100644
--- a/packages/ghosttea-react/src/config.test.ts
+++ b/packages/ghosttea-react/src/config.test.ts
@@ -28,7 +28,11 @@ describe("shared configuration presentation", () => {
selectionForeground: [7 / 255, 8 / 255, 9 / 255, 1],
backgroundOpacityCells: false,
lightAdaptation: "auto",
+ minimumContrast: 1,
});
+ expect(
+ terminalThemeFromConfig({ renderer: { ...renderer, lightAdaptation: "off", minimumContrast: 4.5 } }),
+ ).toMatchObject({ lightAdaptation: "off", minimumContrast: 4.5 });
expect(terminalEffectsFromConfig({ renderer })).toEqual({
postProcess: "better-crt",
shaderEffects: ["ghosttea:better-crt"],
diff --git a/packages/ghosttea-react/src/config.ts b/packages/ghosttea-react/src/config.ts
index ee63c1b2..6ec2aa9b 100644
--- a/packages/ghosttea-react/src/config.ts
+++ b/packages/ghosttea-react/src/config.ts
@@ -23,7 +23,8 @@ export function terminalThemeFromConfig(config: Pick
selection: rgba(renderer.selectionBackground),
selectionForeground: rgba(renderer.selectionForeground),
backgroundOpacityCells: renderer.backgroundOpacityCells ?? false,
- lightAdaptation: "auto",
+ lightAdaptation: renderer.lightAdaptation ?? "auto",
+ minimumContrast: renderer.minimumContrast ?? 1,
};
}
diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts
index 0f89eb71..19840b99 100644
--- a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts
+++ b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts
@@ -1,6 +1,12 @@
import type { StyleDefinition, StyleRun } from "@vibecook/ghosttea-frame";
import { describe, expect, it } from "vitest";
-import { createLightAdaptation, darkPaintedBase, isLightBackground, type Rgb } from "./light-adaptation.js";
+import {
+ createLightAdaptation,
+ darkPaintedBase,
+ ensureContrast,
+ isLightBackground,
+ type Rgb,
+} from "./light-adaptation.js";
import { DEFAULT_THEME, type RenderView, type Rgba, type TerminalTheme } from "./types.js";
const rgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1];
@@ -37,12 +43,59 @@ describe("light adaptation colors", () => {
expect(r - Math.min(g, b)).toBeGreaterThan(20);
});
+ it("keeps a light theme's own ANSI colors when they stay legible", () => {
+ const red: Rgb = [0xd2, 0x0f, 0x39]; // Catppuccin Latte red, designed for light paper
+ const latte = createLightAdaptation([0x1e, 0x1e, 0x2e], rgba([0xef, 0xf1, 0xf5]), rgba([0x4c, 0x4f, 0x69]));
+ expect(bytes(latte.ink(red, latte.base, 1))).toEqual(red);
+ // The same RGB as truecolor is an application color and is remapped.
+ expect(bytes(latte.ink(red, latte.base))).not.toEqual(red);
+ // Neutral entries describe a role, not a hue: "white" text still darkens.
+ const white = bytes(latte.ink([0xbc, 0xc0, 0xcc], latte.base, 7));
+ expect(white[0]).toBeLessThan(0x80);
+ });
+
it("classifies backgrounds the same way the VT shim answers CSI ? 996 n", () => {
expect(isLightBackground([0xef, 0xf1, 0xf5])).toBe(true);
expect(isLightBackground([0x1e, 0x1e, 0x2e])).toBe(false);
});
});
+describe("minimum contrast", () => {
+ const luminance = (color: Rgba): number =>
+ [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
+ const v = color[index]!;
+ return sum + weight * (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
+ }, 0);
+ const ratio = (a: Rgba, b: Rgba): number => {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
+ return (hi + 0.05) / (lo + 0.05);
+ };
+ const panel = rgba([0xe6, 0xe6, 0xe6]);
+
+ it("leaves text alone when it is off or already meets the ratio", () => {
+ const gray = rgba([0x9a, 0x9a, 0x9a]);
+ expect(ensureContrast(gray, panel, 1)).toBe(gray);
+ const ink = rgba([0x26, 0x26, 0x26]);
+ expect(ensureContrast(ink, panel, 4.5)).toBe(ink);
+ });
+
+ it("moves lightness only as far as the ratio needs and keeps the hue", () => {
+ const amber: Rgba = [0xe0 / 255, 0xaf / 255, 0x68 / 255, 0.55];
+ const result = ensureContrast(amber, panel, 4.5);
+ expect(ratio(result, panel)).toBeGreaterThanOrEqual(4.5);
+ expect(ratio(result, panel)).toBeLessThan(4.7);
+ const [r, g, b] = bytes(result);
+ expect(r).toBeGreaterThan(g);
+ expect(g).toBeGreaterThan(b);
+ expect(result[3]).toBe(0.55);
+ });
+
+ it("reaches black or white when nothing less suffices", () => {
+ expect(bytes(ensureContrast(rgba([0x80, 0x80, 0x80]), rgba([0x80, 0x80, 0x80]), 21))).toEqual([0, 0, 0]);
+ expect(bytes(ensureContrast(rgba([0x30, 0x30, 0x30]), rgba([0x20, 0x20, 0x20]), 7))[0]).toBeGreaterThan(0x80);
+ });
+});
+
function view(theme: TerminalTheme, cols: number, rows: number, paintedRows: number): RenderView {
const style: StyleDefinition = {
id: 7,
diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.ts b/packages/ghosttea-react/src/renderers/light-adaptation.ts
index 39ec5e98..b4e164fc 100644
--- a/packages/ghosttea-react/src/renderers/light-adaptation.ts
+++ b/packages/ghosttea-react/src/renderers/light-adaptation.ts
@@ -15,7 +15,12 @@
// accents land in a mid-tone band: APCA Lc = clamp(0.5·|Lc| + 28, 45, 70)
// against the new background, chroma raised toward 0.12.
//
-// Theme-owned colors (default fg/bg) are left to the theme.
+// Theme-owned colors (default fg/bg) are left to the theme, and so are
+// chromatic ANSI colors (palette 1–6, 9–14): the light theme designed them for
+// light paper, so they pass through unless the remapped surface beneath makes
+// them less legible than on the theme background. Neutral ANSI colors (0, 7,
+// 8, 15) carry a role — "bright text", "dim panel" — rather than a hue, so
+// they remap like truecolor.
import type { StyleDefinition } from "@vibecook/ghosttea-frame";
import type { RenderView, Rgba } from "./types.js";
@@ -110,12 +115,6 @@ const smoothstep = (e0: number, e1: number, x: number): number => {
return t * t * (3 - 2 * t);
};
-/** Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. */
-export function isLightBackground([r, g, b]: Rgb): boolean {
- const luminance = 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255);
- return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05);
-}
-
const toRgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1];
const fromRgba = (color: Rgba): Rgb => [
Math.round(clamp(color[0]) * 255),
@@ -124,14 +123,75 @@ const fromRgba = (color: Rgba): Rgb => [
];
const keyOf = ([r, g, b]: Rgb): number => (r << 16) | (g << 8) | b;
+const luminance = ([r, g, b]: Rgb): number =>
+ 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255);
+const contrastRatio = (a: Rgb, b: Rgb): number => {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
+ return (hi + 0.05) / (lo + 0.05);
+};
+
+/** Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. */
+export function isLightBackground(color: Rgb): boolean {
+ const y = luminance(color);
+ return (y + 0.05) / 0.05 > 1.05 / (y + 0.05);
+}
+
+/** null: the text already meets the ratio. */
+const contrasted = new Map();
+
+/**
+ * Ghostty's `minimum-contrast`: text below the WCAG ratio against its
+ * background moves toward whichever of black or white contrasts more. Ghostty
+ * snaps straight to that extreme; this keeps hue and chroma and moves OKLab
+ * lightness only as far as the ratio needs, reaching black or white only when
+ * nothing less suffices.
+ */
+export function ensureContrast(text: Rgba, background: Rgba, ratio: number): Rgba {
+ if (!(ratio > 1)) return text;
+ const fg = fromRgba(text);
+ const bg = fromRgba(background);
+ const cacheKey = `${keyOf(fg)}/${keyOf(bg)}/${ratio}`;
+ let result = contrasted.get(cacheKey);
+ if (result === undefined) {
+ if (contrastRatio(fg, bg) >= ratio) result = null;
+ else {
+ const towardWhite = contrastRatio([255, 255, 255], bg) > contrastRatio([0, 0, 0], bg);
+ const [L, a, b] = oklab(fg);
+ const C = Math.hypot(a, b);
+ const h = Math.atan2(b, a);
+ let near = L;
+ let far = towardWhite ? 1 : 0;
+ let best: Rgb = towardWhite ? [255, 255, 255] : [0, 0, 0];
+ if (contrastRatio(best, bg) >= ratio) {
+ for (let i = 0; i < 20; i += 1) {
+ const mid = (near + far) / 2;
+ const candidate = gamutMap(mid, C, h);
+ if (contrastRatio(candidate, bg) >= ratio) {
+ far = mid;
+ best = candidate;
+ } else near = mid;
+ }
+ }
+ result = best;
+ }
+ if (contrasted.size > 4096) contrasted.clear();
+ contrasted.set(cacheKey, result);
+ }
+ if (result === null) return text;
+ return [result[0] / 255, result[1] / 255, result[2] / 255, text[3]];
+}
+
export interface LightAdaptation {
/** Stable identity for cache keys: base, paper, and ink. */
readonly key: string;
readonly base: Rgb;
/** A color painted as an area: cell backgrounds and block-element art. */
surface(color: Rgb): Rgba;
- /** A glyph color, judged against the background it was designed on. */
- ink(color: Rgb, sourceBackground: Rgb): Rgba;
+ /**
+ * A glyph color, judged against the background it was designed on.
+ * `paletteIndex` is the ANSI entry the color came from, when known.
+ */
+ ink(color: Rgb, sourceBackground: Rgb, paletteIndex?: number): Rgba;
}
export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgba): LightAdaptation {
@@ -181,11 +241,20 @@ export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgb
};
const inks = new Map();
- const ink = (color: Rgb, sourceBackground: Rgb): Rgba => {
- const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}`;
+ const ink = (color: Rgb, sourceBackground: Rgb, paletteIndex?: number): Rgba => {
+ const themeColor = paletteIndex !== undefined && CHROMATIC_ANSI.has(paletteIndex);
+ const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}${themeColor ? "/p" : ""}`;
const cached = inks.get(cacheKey);
if (cached) return cached;
const mapped = surfaceRgb(sourceBackground);
+ if (themeColor) {
+ const legibility = Math.min(45, Math.abs(apca(color, paper)));
+ if (Math.abs(apca(color, mapped)) >= legibility) {
+ const rgba = toRgba(color);
+ inks.set(cacheKey, rgba);
+ return rgba;
+ }
+ }
const [L, a0, b0] = oklab(color);
const C0 = Math.hypot(a0, b0);
// Neutral ink: mirror its Lr distance from the background, and lean
@@ -230,6 +299,8 @@ export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgb
};
}
+const CHROMATIC_ANSI = new Set([1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]);
+
/** Engage when this share of cells carries an explicit dark background… */
export const ENGAGE_SHARE = 0.6;
/** …and stay engaged until it drops below this, so scrolling content does not flicker. */
diff --git a/packages/ghosttea-react/src/renderers/types.ts b/packages/ghosttea-react/src/renderers/types.ts
index 30be77d6..0bddc47e 100644
--- a/packages/ghosttea-react/src/renderers/types.ts
+++ b/packages/ghosttea-react/src/renderers/types.ts
@@ -25,6 +25,12 @@ export interface TerminalTheme {
* engages per pane only when most cells carry an explicit dark background.
*/
lightAdaptation?: "auto" | "off";
+ /**
+ * Ghostty `minimum-contrast`: the WCAG ratio text keeps against its cell
+ * background (1–21; 1 is off). Box drawing, block elements, and color
+ * glyphs are exempt.
+ */
+ minimumContrast?: number;
}
export type TerminalPostProcess = "none" | "better-crt";
diff --git a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts
index c2575118..25cb2461 100644
--- a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts
+++ b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts
@@ -21,7 +21,7 @@ import { graphemeCellWidth, splitGraphemes } from "../cell-width.js";
import { backgroundRunBounds } from "./background-geometry.js";
import { rowsForDamage } from "./render-damage.js";
import { SHADER_EFFECT_WGSL } from "./shader-effects.js";
-import { createLightAdaptation, darkPaintedBase, type LightAdaptation } from "./light-adaptation.js";
+import { createLightAdaptation, darkPaintedBase, ensureContrast, type LightAdaptation } from "./light-adaptation.js";
const ATLAS_SIZE = 2048;
const GEOMETRY_CACHE_LIMIT = 8;
@@ -571,6 +571,7 @@ function geometryCacheKey(
...view.theme.selectionForeground,
].join(","),
view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells",
+ view.theme.minimumContrast ?? 1,
adaptationKey,
selection,
effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-",
@@ -856,6 +857,8 @@ interface ResolvedStyle {
background: Rgba | null;
/** Foreground used as an area fill (block elements); differs only under light adaptation. */
fill: Rgba;
+ /** Foreground for text glyphs and their decorations, after `minimum-contrast`. */
+ text: Rgba;
underline: boolean;
strikethrough: boolean;
invisible: boolean;
@@ -876,12 +879,12 @@ function resolveStyle(
if (style?.inverse) {
background = source.foreground ? adaptation.surface(source.foreground) : theme.foreground;
foreground = source.background
- ? adaptation.ink(source.background, source.foreground ?? adaptation.base)
+ ? adaptation.ink(source.background, source.foreground ?? adaptation.base, style?.backgroundPalette)
: theme.background;
fill = foreground;
} else {
foreground = source.foreground
- ? adaptation.ink(source.foreground, source.background ?? adaptation.base)
+ ? adaptation.ink(source.foreground, source.background ?? adaptation.base, style?.foregroundPalette)
: theme.foreground;
background = source.background ? adaptation.surface(source.background) : null;
fill = source.foreground ? adaptation.surface(source.foreground) : theme.foreground;
@@ -895,14 +898,23 @@ function resolveStyle(
if (background && theme.backgroundOpacityCells) {
background = [background[0], background[1], background[2], theme.background[3]];
}
+ // Graphics (box drawing, block elements) and color glyphs keep the raw
+ // foreground, as in Ghostty; the backdrop ignores window transparency.
+ let text = ensureContrast(
+ foreground,
+ over(background ?? theme.background, theme.background),
+ theme.minimumContrast ?? 1,
+ );
if (style?.faint) {
foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55];
fill = [fill[0], fill[1], fill[2], fill[3] * 0.55];
+ text = [text[0], text[1], text[2], text[3] * 0.55];
}
return {
foreground,
background,
fill,
+ text,
underline: style?.underline ?? false,
strikethrough: style?.strikethrough ?? false,
invisible: style?.invisible ?? false,
@@ -1672,8 +1684,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
)
continue;
}
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
+ const mono = definition.format === GlyphFormat.Alpha8;
+ const atlas = mono ? this.#monoAtlas : this.#colorAtlas;
+ const vertices = mono ? glyphVertices : colorGlyphVertices;
pushGlyph(
vertices,
(ORIGIN_X + instance.x) * scale,
@@ -1681,7 +1694,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
instance.width * scale,
instance.height * scale,
atlas.glyph(definition),
- foreground,
+ mono && foreground === style.foreground ? style.text : foreground,
viewportWidth,
viewportHeight,
);
@@ -1731,7 +1744,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
Math.round(rowTop + 16 * scale),
width,
stroke,
- style.foreground,
+ style.text,
viewportWidth,
viewportHeight,
);
@@ -1743,7 +1756,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
Math.round(rowTop + 9 * scale),
width,
stroke,
- style.foreground,
+ style.text,
viewportWidth,
viewportHeight,
);
@@ -1932,8 +1945,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
)
continue;
}
- const atlas = definition.format === GlyphFormat.Alpha8 ? this.#monoAtlas : this.#colorAtlas;
- const vertices = definition.format === GlyphFormat.Alpha8 ? glyphVertices : colorGlyphVertices;
+ const mono = definition.format === GlyphFormat.Alpha8;
+ const atlas = mono ? this.#monoAtlas : this.#colorAtlas;
+ const vertices = mono ? glyphVertices : colorGlyphVertices;
pushGlyph(
vertices,
(ORIGIN_X + instance.x) * scale,
@@ -1941,7 +1955,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
instance.width * scale,
instance.height * scale,
atlas.glyph(definition),
- foreground,
+ mono && foreground === style.foreground ? style.text : foreground,
viewportWidth,
viewportHeight,
);
@@ -1991,7 +2005,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
Math.round(rowTop + 16 * scale),
width,
stroke,
- style.foreground,
+ style.text,
viewportWidth,
viewportHeight,
);
@@ -2003,7 +2017,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer {
Math.round(rowTop + 9 * scale),
width,
stroke,
- style.foreground,
+ style.text,
viewportWidth,
viewportHeight,
);
diff --git a/packages/ghosttea-react/src/terminal-render.worker.ts b/packages/ghosttea-react/src/terminal-render.worker.ts
index 824a37b4..459a46c4 100644
--- a/packages/ghosttea-react/src/terminal-render.worker.ts
+++ b/packages/ghosttea-react/src/terminal-render.worker.ts
@@ -830,7 +830,8 @@ function applyFrame(
}
const glyphDefinitions = glyphSection ? decodeGlyphDefinitions(glyphSection) : NO_GLYPH_DEFINITIONS;
- const styleDefinitions = styleSection ? decodeStyleDefinitions(styleSection) : NO_STYLE_DEFINITIONS;
+ const paletteSection = frame.sections.find((candidate) => candidate.kind === SectionKind.StylePalette);
+ const styleDefinitions = styleSection ? decodeStyleDefinitions(styleSection, paletteSection) : NO_STYLE_DEFINITIONS;
const replacements = decodeRowReplacements(rowSection);
for (const replacement of replacements) {
if (replacement.row >= frame.rows) throw new RangeError("Row replacement exceeds viewport");