Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apple/GhostteaApp/App/GhostteaAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apple/GhostteaApp/App/GhostteaSSHAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,8 @@ extension GhostteaTerminalPresentationConfig {
palette: palette,
backgroundOpacity: backgroundOpacity,
backgroundOpacityCells: backgroundOpacityCells,
minimumContrast: minimumContrast,
lightAdaptation: lightAdaptation,
fontSize: runtime.fontSize,
fontFamilies: runtime.fontFamilies,
paddingX: paddingX,
Expand Down
34 changes: 34 additions & 0 deletions apple/GhostteaApp/App/GhostteaSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 }
})
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))"
}
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions apple/GhostteaKit/Sources/GhostteaCore/GhostteaConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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],
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 95 additions & 2 deletions apple/GhostteaKit/Sources/GhostteaFrame/TRF1Decoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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..<Int(count) {
let offset = 4 + index * 8
let flags = try reader.uint8(offset + 4, "truncated style palette entry")
try require(flags & ~0x3 == 0, "invalid style palette flags")
let foreground = try reader.uint8(offset + 5, "truncated style palette entry")
let background = try reader.uint8(offset + 6, "truncated style palette entry")
entries.append(
TRF1StylePaletteEntry(
styleID: try reader.uint32(offset, "truncated style palette entry"),
foreground: flags & 1 != 0 ? foreground : nil,
background: flags & 2 != 0 ? background : nil
)
)
}
return entries
}

public func decodeTRF1CursorState(_ section: TRF1Section) throws -> TRF1CursorState {
Expand Down
Loading
Loading