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

This file was deleted.

1 change: 1 addition & 0 deletions macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,7 @@ extension AppThemePreference {
case .dark: true
}
}

var preferredColorScheme: ColorScheme? {
switch self {
case .system: nil
Expand Down
8 changes: 6 additions & 2 deletions macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ extension AppModel {
let session = feature.createSession(
in: workspaceURL,
shellPath: shellPath ?? settings.terminalShellPath,
preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }
preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent },
showsWorkbenchBackground: workbenchBackgroundFeature.hasImage
)
configureTerminalSession(session)
terminalPlacementFeature.registerSession(session.id)
Expand Down Expand Up @@ -131,7 +132,10 @@ extension AppModel {
TerminalEnvironmentChange(name: $0.name, value: $0.value)
}
)
let created = try feature.createProcessSession(launch) { [weak self] output in
let created = try feature.createProcessSession(
launch,
showsWorkbenchBackground: workbenchBackgroundFeature.hasImage
) { [weak self] output in
self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output)
}
configureTerminalSession(created.session)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class LitheTerminalView: LocalProcessTerminalView {
/// App theme light/dark preference while the view is not yet in a window; nil follows the system appearance.
var preferredAppearanceIsDark: (() -> Bool?)?
private var lastReportedBackgroundPayload: String?
private var backgroundChangeNotifications = MacTerminalBackgroundSubscription()

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
Expand Down Expand Up @@ -158,19 +159,20 @@ final class LitheTerminalView: LocalProcessTerminalView {

override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
applyThemeColors()
applyThemeColors(theme: LitheTheme.activeTheme)
}

func applyThemeColors() {
// Outside a window, effectiveAppearance follows the system; the app theme decides the reported background.
func applyThemeColors(theme: AppColorTheme, isDark requestedIsDark: Bool? = nil) {
let isDark: Bool
if window == nil, let preferredAppearanceIsDark {
if let requestedIsDark {
isDark = requestedIsDark
} else if window == nil, let preferredAppearanceIsDark {
isDark = preferredAppearanceIsDark()
?? (effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua)
} else {
isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
let editorColor = LitheTheme.nsColor(.editor, isDark: isDark)
let editorColor = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark)
let palette = MacTerminalPalette(
preferences: UserDefaults.standard.persistentDomain(forName: "com.apple.Terminal"),
backgroundColor: editorColor
Expand All @@ -192,7 +194,7 @@ final class LitheTerminalView: LocalProcessTerminalView {
// Terminal.app profiles may use a foreground color that assumes their
// own opaque background. The terminal is composited over Lithe's
// editor or wallpaper, so keep the default text tied to the app theme.
nativeForegroundColor = LitheTheme.nsColor(.primaryText, isDark: isDark)
nativeForegroundColor = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark)
caretColor = isDark
? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1)
: NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1)
Expand All @@ -212,38 +214,51 @@ final class LitheTerminalView: LocalProcessTerminalView {
reportBackgroundChangeIfNeeded()
}

/// On reported-background change, sends the OSC 11 answer plus kitty DSR 997; trackers re-query, others ignore.
/// Reports background changes only to programs subscribed to kitty DEC mode 2031.
private func reportBackgroundChangeIfNeeded() {
let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor)
guard payload != lastReportedBackgroundPayload else { return }
lastReportedBackgroundPayload = payload
guard process.running else { return }
terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor))
guard process.running,
let notification = MacTerminalBackgroundReporting.changeNotification(
for: nativeBackgroundColor,
isSubscribed: backgroundChangeNotifications.isSubscribed
) else { return }
terminal.sendResponse(text: notification)
}

func setWorkbenchBackgroundVisible(_ isVisible: Bool) {
guard showsWorkbenchBackground != isVisible else { return }
func updateWorkbenchSurface(isVisible: Bool) {
showsWorkbenchBackground = isVisible
applyThemeColors()
applyThemeColors(theme: LitheTheme.activeTheme)
}

func updateWorkbenchSurface(isVisible: Bool, theme: AppColorTheme, isDark: Bool) {
showsWorkbenchBackground = isVisible
applyThemeColors(theme: theme, isDark: isDark)
}


override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) {
onOpenLink?(link, params)
}

override func dataReceived(slice: ArraySlice<UInt8>) {
backgroundChangeNotifications.feed(slice)
onProcessOutput?(Data(slice))
super.dataReceived(slice: slice)
}

func resetBackgroundChangeNotifications() {
backgroundChangeNotifications.reset()
}

deinit {
NotificationCenter.default.removeObserver(self)
}
}

extension LitheTerminalView: WorkbenchBackgroundRendering {}

/// Report pushed when the background changes: SwiftTerm's own OSC 11 answer plus the kitty DSR 997 notification.
/// Reports changed backgrounds to subscribers; kitty DSR 997 uses 1 for dark and 2 for light.
enum MacTerminalBackgroundReporting {
static func osc11Payload(for color: NSColor) -> String {
let srgb = color.usingColorSpace(.sRGB) ?? color
Expand All @@ -255,8 +270,99 @@ enum MacTerminalBackgroundReporting {
return "rgb:\(channel(red))/\(channel(green))/\(channel(blue))"
}

static func changeNotification(for color: NSColor) -> String {
"\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;2n"
static func reportsDarkBackground(for color: NSColor) -> Bool {
let srgb = color.usingColorSpace(.sRGB) ?? color
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1
srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
func linearize(_ component: CGFloat) -> Double {
let value = min(1, max(0, Double(component)))
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
return luminance < 0.5
}

static func changeNotification(for color: NSColor, isSubscribed: Bool) -> String? {
guard isSubscribed else { return nil }
let backgroundState = reportsDarkBackground(for: color) ? 1 : 2
return "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;\(backgroundState)n"
}
}

/// Tracks kitty DEC mode 2031 so change notifications are sent only to programs that requested them.
struct MacTerminalBackgroundSubscription {
private(set) var isSubscribed = false
private var pendingEscape: [UInt8] = []

private static let escape = UInt8(0x1b)
private static let mode2031: [UInt8] = [0x32, 0x30, 0x33, 0x31]
private static let altScreenModes: [[UInt8]] = [
[0x34, 0x37],
[0x31, 0x30, 0x34, 0x37],
[0x31, 0x30, 0x34, 0x39],
]
private static let maximumSequenceLength = 64

init() {
pendingEscape.reserveCapacity(Self.maximumSequenceLength)
}

mutating func feed<S: Sequence>(_ bytes: S) where S.Element == UInt8 {
for byte in bytes {
if pendingEscape.isEmpty {
if byte == Self.escape { pendingEscape.append(byte) }
continue
}
if byte == Self.escape {
pendingEscape = [Self.escape]
continue
}
pendingEscape.append(byte)
if pendingEscape == [Self.escape, 0x63] {
isSubscribed = false
pendingEscape.removeAll(keepingCapacity: true)
continue
}
if pendingEscape.count == 2 {
if byte != 0x5b { pendingEscape.removeAll(keepingCapacity: true) }
continue
}
if pendingEscape.count == 3 {
if byte != 0x3f { pendingEscape.removeAll(keepingCapacity: true) }
continue
}
if (0x40...0x7e).contains(byte) {
if byte == 0x68 || byte == 0x6c,
contains(Self.mode2031, in: pendingEscape[3..<(pendingEscape.count - 1)]) {
isSubscribed = byte == 0x68
} else if byte == 0x6c,
leavesAltScreen(pendingEscape[3..<(pendingEscape.count - 1)]) {
// A subscribed TUI runs inside the alternate screen; when it leaves, the shell must not inherit pushes.
isSubscribed = false
}
pendingEscape.removeAll(keepingCapacity: true)
} else if pendingEscape.count >= Self.maximumSequenceLength {
pendingEscape.removeAll(keepingCapacity: true)
}
}
}

mutating func reset() {
isSubscribed = false
pendingEscape.removeAll(keepingCapacity: true)
}

private func contains(_ parameter: [UInt8], in parameters: ArraySlice<UInt8>) -> Bool {
var start = parameters.startIndex
for index in parameters.indices where parameters[index] == 0x3b {
if parameters[start..<index].elementsEqual(parameter) { return true }
start = parameters.index(after: index)
}
return parameters[start..<parameters.endIndex].elementsEqual(parameter)
}

private func leavesAltScreen(_ parameters: ArraySlice<UInt8>) -> Bool {
Self.altScreenModes.contains { contains($0, in: parameters) }
}
}

Expand Down Expand Up @@ -428,6 +534,13 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L

var nativeView: AnyObject { view }

func configureInitialSurface(showsWorkbenchBackground: Bool) {
view.updateWorkbenchSurface(isVisible: showsWorkbenchBackground)
}

// Keep the exported COLORFGBG in sync with the OSC 11 answer: wallpaper transparency reports black, so it must export dark.
var reportsLightBackground: Bool? { !MacTerminalBackgroundReporting.reportsDarkBackground(for: view.nativeBackgroundColor) }

private let scrollbackLines: Int

init(scrollbackLines: Int = 2_000, preferredAppearanceIsDark: (() -> Bool?)? = nil) {
Expand All @@ -444,7 +557,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
}
view.font = Self.preferredTerminalFont()
view.preferredAppearanceIsDark = preferredAppearanceIsDark
view.applyThemeColors()
view.applyThemeColors(theme: LitheTheme.activeTheme)
view.allowMouseReporting = true
view.linkReporting = .implicit
view.linkHighlightMode = .hoverWithModifier
Expand Down Expand Up @@ -526,6 +639,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
selectedShellPath = executablePath

view.terminal.resetToInitialState()
view.resetBackgroundChangeNotifications()
var options = view.terminal.options
options.termName = environment["TERM"] ?? "xterm-256color"
view.terminal.options = options
Expand Down
38 changes: 30 additions & 8 deletions macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import LitheTerminalModule

struct TerminalSurfaceView: View {
@ObservedObject var session: TerminalSession
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var settings: AppSettings
@State private var focusRequestID = 0

var body: some View {
Expand All @@ -13,6 +15,8 @@ struct TerminalSurfaceView: View {
TerminalNativeSurface(
nativeView: nativeView,
focusRequestID: focusRequestID,
theme: settings.colorTheme,
isDark: colorScheme == .dark,
showsWorkbenchBackground: model.workbenchBackgroundFeature.hasImage
)
} else {
Expand Down Expand Up @@ -71,24 +75,38 @@ struct TerminalSurfaceView: View {
private struct TerminalNativeSurface: NSViewRepresentable {
let nativeView: NSView
let focusRequestID: Int
let theme: AppColorTheme
let isDark: Bool
let showsWorkbenchBackground: Bool

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> TerminalSurfaceHostView {
let hostView = TerminalSurfaceHostView(frame: .zero)
hostView.attach(nativeView)
(nativeView as? WorkbenchBackgroundRendering)?
.setWorkbenchBackgroundVisible(showsWorkbenchBackground)
(nativeView as? LitheTerminalView)?.updateWorkbenchSurface(
isVisible: showsWorkbenchBackground,
theme: theme,
isDark: isDark
)
context.coordinator.lastTheme = theme
context.coordinator.lastIsDark = isDark
context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground
return hostView
}

func updateNSView(_ hostView: TerminalSurfaceHostView, context: Context) {
hostView.attach(nativeView)
(nativeView as? WorkbenchBackgroundRendering)?
.setWorkbenchBackgroundVisible(showsWorkbenchBackground)
if context.coordinator.lastTheme != theme || context.coordinator.lastIsDark != isDark
|| context.coordinator.lastShowsWorkbenchBackground != showsWorkbenchBackground {
(nativeView as? LitheTerminalView)?.updateWorkbenchSurface(
isVisible: showsWorkbenchBackground,
theme: theme,
isDark: isDark
)
context.coordinator.lastTheme = theme
context.coordinator.lastIsDark = isDark
context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground
}
guard context.coordinator.lastFocusRequestID != focusRequestID else { return }
context.coordinator.lastFocusRequestID = focusRequestID
DispatchQueue.main.async {
Expand All @@ -106,9 +124,13 @@ private struct TerminalNativeSurface: NSViewRepresentable {

final class Coordinator: NSObject {
var lastFocusRequestID = -1
var lastTheme: AppColorTheme?
var lastIsDark: Bool?
var lastShowsWorkbenchBackground: Bool?
}
}


private final class TerminalSurfaceHostView: NSView {
private weak var terminalView: NSView?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ public final class TerminalFeatureModel: ObservableObject {
public func createSession(
in workspaceURL: URL,
shellPath: String? = nil,
preferredToolTabTitle: String? = nil
preferredToolTabTitle: String? = nil,
showsWorkbenchBackground: Bool = false
) -> TerminalSession {
let session = TerminalSession(transport: terminalFactory(), preferredToolTabTitle: preferredToolTabTitle)
let transport = terminalFactory()
transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground)
let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle)
session.start(in: workspaceURL, shellPath: shellPath)
terminalSessions.append(session)
activeTerminalSessionID = session.id
Expand All @@ -60,9 +63,12 @@ public final class TerminalFeatureModel: ObservableObject {
@discardableResult
public func createProcessSession(
_ launch: TerminalProcessLaunch,
showsWorkbenchBackground: Bool = false,
onOutput: ((String) -> Void)? = nil
) throws -> (session: TerminalSession, processID: Int32) {
let session = TerminalSession(transport: terminalFactory())
let transport = terminalFactory()
transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground)
let session = TerminalSession(transport: transport)
session.onOutput = onOutput
let processID = try session.startProcess(launch)
terminalSessions.append(session)
Expand Down
Loading
Loading