Skip to content

Commit f0072c9

Browse files
authored
fix(macos): 终端配色同步: 主题刷新、COLORFGBG 与订阅制背景通知 (#1448)
* fix(macos): 刷新已打开终端的主题色 * feat(macos): 终端环境导出 COLORFGBG - `COLORFGBG` 是 Terminal.app、iTerm2、tmux 等终端的行业惯例环境变量;omp 等 TUI 在进程启动时同步读取其 bg 字段(<8 深、>=8 浅)选择配色,先于任何 OSC 往返,可消除首帧按回退深色渲染的闪变。 - Lithe 终端此前未导出该变量;且终端视图加入窗口前 effectiveAppearance 跟随系统外观,直接读它会与 Lithe 主题不一致。 - `TerminalTransport` 协议新增 `reportsLightBackground: Bool?`(宿主是否渲染浅色背景,无法判定时为 nil),extension 默认 nil - `TerminalSession` 以可选值直赋导出 `COLORFGBG`(浅色 `0;15`、深色 `15;0`;fg;bg 惯例,判读位是 bg 字段),宿主无法判定时同时清除从外层终端继承的同名值 - macOS 实现按应用主题偏好解析:视图未挂载窗口时用主题 pin 的明暗,挂载后用窗口外观 - `TerminalModuleTests` 新增 4 个用例(浅色、深色、无法判定不写入、清除继承值);更新 `terminal-shell.json` 能力矩阵 - `TerminalModuleTests` 全绿(含 4 个新增) - 平台能力矩阵门禁通过 - 真机浅色/深色首帧配色正确,无闪变;`echo $COLORFGBG` 输出 `0;15` / `15;0` * fix(macos): 背景通知仅推送给订阅 kitty DEC mode 2031 的程序 - 背景变化通知由无条件推送改为按订阅发送: 解析进程输出中的 CSI ? 2031 h/l (含组合 DEC mode 与跨块分片), 未订阅的 shell 不再收到响应字节 - DSR 997 按背景亮度如实报告: 997;1n 暗色、997;2n 亮色 - 新进程启动与终端 RIS 重置清除订阅状态 - 单测 15 用例: 通知编码门控与明暗状态 + 订阅解析全路径
1 parent 7661301 commit f0072c9

15 files changed

Lines changed: 569 additions & 67 deletions

File tree

‎macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎macos/Sources/Lithe/LitheApp.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,7 @@ extension AppThemePreference {
13141314
case .dark: true
13151315
}
13161316
}
1317+
13171318
var preferredColorScheme: ColorScheme? {
13181319
switch self {
13191320
case .system: nil

‎macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ extension AppModel {
6161
let session = feature.createSession(
6262
in: workspaceURL,
6363
shellPath: shellPath ?? settings.terminalShellPath,
64-
preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }
64+
preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent },
65+
showsWorkbenchBackground: workbenchBackgroundFeature.hasImage
6566
)
6667
configureTerminalSession(session)
6768
terminalPlacementFeature.registerSession(session.id)
@@ -131,7 +132,10 @@ extension AppModel {
131132
TerminalEnvironmentChange(name: $0.name, value: $0.value)
132133
}
133134
)
134-
let created = try feature.createProcessSession(launch) { [weak self] output in
135+
let created = try feature.createProcessSession(
136+
launch,
137+
showsWorkbenchBackground: workbenchBackgroundFeature.hasImage
138+
) { [weak self] output in
135139
self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output)
136140
}
137141
configureTerminalSession(created.session)

‎macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift‎

Lines changed: 131 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class LitheTerminalView: LocalProcessTerminalView {
1717
/// App theme light/dark preference while the view is not yet in a window; nil follows the system appearance.
1818
var preferredAppearanceIsDark: (() -> Bool?)?
1919
private var lastReportedBackgroundPayload: String?
20+
private var backgroundChangeNotifications = MacTerminalBackgroundSubscription()
2021

2122
override init(frame frameRect: NSRect) {
2223
super.init(frame: frameRect)
@@ -158,19 +159,20 @@ final class LitheTerminalView: LocalProcessTerminalView {
158159

159160
override func viewDidChangeEffectiveAppearance() {
160161
super.viewDidChangeEffectiveAppearance()
161-
applyThemeColors()
162+
applyThemeColors(theme: LitheTheme.activeTheme)
162163
}
163164

164-
func applyThemeColors() {
165-
// Outside a window, effectiveAppearance follows the system; the app theme decides the reported background.
165+
func applyThemeColors(theme: AppColorTheme, isDark requestedIsDark: Bool? = nil) {
166166
let isDark: Bool
167-
if window == nil, let preferredAppearanceIsDark {
167+
if let requestedIsDark {
168+
isDark = requestedIsDark
169+
} else if window == nil, let preferredAppearanceIsDark {
168170
isDark = preferredAppearanceIsDark()
169171
?? (effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua)
170172
} else {
171173
isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
172174
}
173-
let editorColor = LitheTheme.nsColor(.editor, isDark: isDark)
175+
let editorColor = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark)
174176
let palette = MacTerminalPalette(
175177
preferences: UserDefaults.standard.persistentDomain(forName: "com.apple.Terminal"),
176178
backgroundColor: editorColor
@@ -192,7 +194,7 @@ final class LitheTerminalView: LocalProcessTerminalView {
192194
// Terminal.app profiles may use a foreground color that assumes their
193195
// own opaque background. The terminal is composited over Lithe's
194196
// editor or wallpaper, so keep the default text tied to the app theme.
195-
nativeForegroundColor = LitheTheme.nsColor(.primaryText, isDark: isDark)
197+
nativeForegroundColor = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark)
196198
caretColor = isDark
197199
? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1)
198200
: NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1)
@@ -212,38 +214,51 @@ final class LitheTerminalView: LocalProcessTerminalView {
212214
reportBackgroundChangeIfNeeded()
213215
}
214216

215-
/// On reported-background change, sends the OSC 11 answer plus kitty DSR 997; trackers re-query, others ignore.
217+
/// Reports background changes only to programs subscribed to kitty DEC mode 2031.
216218
private func reportBackgroundChangeIfNeeded() {
217219
let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor)
218220
guard payload != lastReportedBackgroundPayload else { return }
219221
lastReportedBackgroundPayload = payload
220-
guard process.running else { return }
221-
terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor))
222+
guard process.running,
223+
let notification = MacTerminalBackgroundReporting.changeNotification(
224+
for: nativeBackgroundColor,
225+
isSubscribed: backgroundChangeNotifications.isSubscribed
226+
) else { return }
227+
terminal.sendResponse(text: notification)
222228
}
223229

224-
func setWorkbenchBackgroundVisible(_ isVisible: Bool) {
225-
guard showsWorkbenchBackground != isVisible else { return }
230+
func updateWorkbenchSurface(isVisible: Bool) {
226231
showsWorkbenchBackground = isVisible
227-
applyThemeColors()
232+
applyThemeColors(theme: LitheTheme.activeTheme)
228233
}
229234

235+
func updateWorkbenchSurface(isVisible: Bool, theme: AppColorTheme, isDark: Bool) {
236+
showsWorkbenchBackground = isVisible
237+
applyThemeColors(theme: theme, isDark: isDark)
238+
}
239+
240+
230241
override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) {
231242
onOpenLink?(link, params)
232243
}
233244

234245
override func dataReceived(slice: ArraySlice<UInt8>) {
246+
backgroundChangeNotifications.feed(slice)
235247
onProcessOutput?(Data(slice))
236248
super.dataReceived(slice: slice)
237249
}
238250

251+
func resetBackgroundChangeNotifications() {
252+
backgroundChangeNotifications.reset()
253+
}
254+
239255
deinit {
240256
NotificationCenter.default.removeObserver(self)
241257
}
242258
}
243259

244-
extension LitheTerminalView: WorkbenchBackgroundRendering {}
245260

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

258-
static func changeNotification(for color: NSColor) -> String {
259-
"\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;2n"
273+
static func reportsDarkBackground(for color: NSColor) -> Bool {
274+
let srgb = color.usingColorSpace(.sRGB) ?? color
275+
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1
276+
srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
277+
func linearize(_ component: CGFloat) -> Double {
278+
let value = min(1, max(0, Double(component)))
279+
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
280+
}
281+
let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
282+
return luminance < 0.5
283+
}
284+
285+
static func changeNotification(for color: NSColor, isSubscribed: Bool) -> String? {
286+
guard isSubscribed else { return nil }
287+
let backgroundState = reportsDarkBackground(for: color) ? 1 : 2
288+
return "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;\(backgroundState)n"
289+
}
290+
}
291+
292+
/// Tracks kitty DEC mode 2031 so change notifications are sent only to programs that requested them.
293+
struct MacTerminalBackgroundSubscription {
294+
private(set) var isSubscribed = false
295+
private var pendingEscape: [UInt8] = []
296+
297+
private static let escape = UInt8(0x1b)
298+
private static let mode2031: [UInt8] = [0x32, 0x30, 0x33, 0x31]
299+
private static let altScreenModes: [[UInt8]] = [
300+
[0x34, 0x37],
301+
[0x31, 0x30, 0x34, 0x37],
302+
[0x31, 0x30, 0x34, 0x39],
303+
]
304+
private static let maximumSequenceLength = 64
305+
306+
init() {
307+
pendingEscape.reserveCapacity(Self.maximumSequenceLength)
308+
}
309+
310+
mutating func feed<S: Sequence>(_ bytes: S) where S.Element == UInt8 {
311+
for byte in bytes {
312+
if pendingEscape.isEmpty {
313+
if byte == Self.escape { pendingEscape.append(byte) }
314+
continue
315+
}
316+
if byte == Self.escape {
317+
pendingEscape = [Self.escape]
318+
continue
319+
}
320+
pendingEscape.append(byte)
321+
if pendingEscape == [Self.escape, 0x63] {
322+
isSubscribed = false
323+
pendingEscape.removeAll(keepingCapacity: true)
324+
continue
325+
}
326+
if pendingEscape.count == 2 {
327+
if byte != 0x5b { pendingEscape.removeAll(keepingCapacity: true) }
328+
continue
329+
}
330+
if pendingEscape.count == 3 {
331+
if byte != 0x3f { pendingEscape.removeAll(keepingCapacity: true) }
332+
continue
333+
}
334+
if (0x40...0x7e).contains(byte) {
335+
if byte == 0x68 || byte == 0x6c,
336+
contains(Self.mode2031, in: pendingEscape[3..<(pendingEscape.count - 1)]) {
337+
isSubscribed = byte == 0x68
338+
} else if byte == 0x6c,
339+
leavesAltScreen(pendingEscape[3..<(pendingEscape.count - 1)]) {
340+
// A subscribed TUI runs inside the alternate screen; when it leaves, the shell must not inherit pushes.
341+
isSubscribed = false
342+
}
343+
pendingEscape.removeAll(keepingCapacity: true)
344+
} else if pendingEscape.count >= Self.maximumSequenceLength {
345+
pendingEscape.removeAll(keepingCapacity: true)
346+
}
347+
}
348+
}
349+
350+
mutating func reset() {
351+
isSubscribed = false
352+
pendingEscape.removeAll(keepingCapacity: true)
353+
}
354+
355+
private func contains(_ parameter: [UInt8], in parameters: ArraySlice<UInt8>) -> Bool {
356+
var start = parameters.startIndex
357+
for index in parameters.indices where parameters[index] == 0x3b {
358+
if parameters[start..<index].elementsEqual(parameter) { return true }
359+
start = parameters.index(after: index)
360+
}
361+
return parameters[start..<parameters.endIndex].elementsEqual(parameter)
362+
}
363+
364+
private func leavesAltScreen(_ parameters: ArraySlice<UInt8>) -> Bool {
365+
Self.altScreenModes.contains { contains($0, in: parameters) }
260366
}
261367
}
262368

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

429535
var nativeView: AnyObject { view }
430536

537+
func configureInitialSurface(showsWorkbenchBackground: Bool) {
538+
view.updateWorkbenchSurface(isVisible: showsWorkbenchBackground)
539+
}
540+
541+
// Keep the exported COLORFGBG in sync with the OSC 11 answer: wallpaper transparency reports black, so it must export dark.
542+
var reportsLightBackground: Bool? { !MacTerminalBackgroundReporting.reportsDarkBackground(for: view.nativeBackgroundColor) }
543+
431544
private let scrollbackLines: Int
432545

433546
init(scrollbackLines: Int = 2_000, preferredAppearanceIsDark: (() -> Bool?)? = nil) {
@@ -444,7 +557,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
444557
}
445558
view.font = Self.preferredTerminalFont()
446559
view.preferredAppearanceIsDark = preferredAppearanceIsDark
447-
view.applyThemeColors()
560+
view.applyThemeColors(theme: LitheTheme.activeTheme)
448561
view.allowMouseReporting = true
449562
view.linkReporting = .implicit
450563
view.linkHighlightMode = .hoverWithModifier
@@ -526,6 +639,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
526639
selectedShellPath = executablePath
527640

528641
view.terminal.resetToInitialState()
642+
view.resetBackgroundChangeNotifications()
529643
var options = view.terminal.options
530644
options.termName = environment["TERM"] ?? "xterm-256color"
531645
view.terminal.options = options

‎macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import LitheTerminalModule
44

55
struct TerminalSurfaceView: View {
66
@ObservedObject var session: TerminalSession
7+
@Environment(\.colorScheme) private var colorScheme
78
@EnvironmentObject private var model: AppModel
9+
@EnvironmentObject private var settings: AppSettings
810
@State private var focusRequestID = 0
911

1012
var body: some View {
@@ -13,6 +15,8 @@ struct TerminalSurfaceView: View {
1315
TerminalNativeSurface(
1416
nativeView: nativeView,
1517
focusRequestID: focusRequestID,
18+
theme: settings.colorTheme,
19+
isDark: colorScheme == .dark,
1620
showsWorkbenchBackground: model.workbenchBackgroundFeature.hasImage
1721
)
1822
} else {
@@ -71,24 +75,38 @@ struct TerminalSurfaceView: View {
7175
private struct TerminalNativeSurface: NSViewRepresentable {
7276
let nativeView: NSView
7377
let focusRequestID: Int
78+
let theme: AppColorTheme
79+
let isDark: Bool
7480
let showsWorkbenchBackground: Bool
7581

76-
func makeCoordinator() -> Coordinator {
77-
Coordinator()
78-
}
79-
82+
func makeCoordinator() -> Coordinator { Coordinator() }
8083
func makeNSView(context: Context) -> TerminalSurfaceHostView {
8184
let hostView = TerminalSurfaceHostView(frame: .zero)
8285
hostView.attach(nativeView)
83-
(nativeView as? WorkbenchBackgroundRendering)?
84-
.setWorkbenchBackgroundVisible(showsWorkbenchBackground)
86+
(nativeView as? LitheTerminalView)?.updateWorkbenchSurface(
87+
isVisible: showsWorkbenchBackground,
88+
theme: theme,
89+
isDark: isDark
90+
)
91+
context.coordinator.lastTheme = theme
92+
context.coordinator.lastIsDark = isDark
93+
context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground
8594
return hostView
8695
}
8796

8897
func updateNSView(_ hostView: TerminalSurfaceHostView, context: Context) {
8998
hostView.attach(nativeView)
90-
(nativeView as? WorkbenchBackgroundRendering)?
91-
.setWorkbenchBackgroundVisible(showsWorkbenchBackground)
99+
if context.coordinator.lastTheme != theme || context.coordinator.lastIsDark != isDark
100+
|| context.coordinator.lastShowsWorkbenchBackground != showsWorkbenchBackground {
101+
(nativeView as? LitheTerminalView)?.updateWorkbenchSurface(
102+
isVisible: showsWorkbenchBackground,
103+
theme: theme,
104+
isDark: isDark
105+
)
106+
context.coordinator.lastTheme = theme
107+
context.coordinator.lastIsDark = isDark
108+
context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground
109+
}
92110
guard context.coordinator.lastFocusRequestID != focusRequestID else { return }
93111
context.coordinator.lastFocusRequestID = focusRequestID
94112
DispatchQueue.main.async {
@@ -106,9 +124,13 @@ private struct TerminalNativeSurface: NSViewRepresentable {
106124

107125
final class Coordinator: NSObject {
108126
var lastFocusRequestID = -1
127+
var lastTheme: AppColorTheme?
128+
var lastIsDark: Bool?
129+
var lastShowsWorkbenchBackground: Bool?
109130
}
110131
}
111132

133+
112134
private final class TerminalSurfaceHostView: NSView {
113135
private weak var terminalView: NSView?
114136

‎macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ public final class TerminalFeatureModel: ObservableObject {
4848
public func createSession(
4949
in workspaceURL: URL,
5050
shellPath: String? = nil,
51-
preferredToolTabTitle: String? = nil
51+
preferredToolTabTitle: String? = nil,
52+
showsWorkbenchBackground: Bool = false
5253
) -> TerminalSession {
53-
let session = TerminalSession(transport: terminalFactory(), preferredToolTabTitle: preferredToolTabTitle)
54+
let transport = terminalFactory()
55+
transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground)
56+
let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle)
5457
session.start(in: workspaceURL, shellPath: shellPath)
5558
terminalSessions.append(session)
5659
activeTerminalSessionID = session.id
@@ -60,9 +63,12 @@ public final class TerminalFeatureModel: ObservableObject {
6063
@discardableResult
6164
public func createProcessSession(
6265
_ launch: TerminalProcessLaunch,
66+
showsWorkbenchBackground: Bool = false,
6367
onOutput: ((String) -> Void)? = nil
6468
) throws -> (session: TerminalSession, processID: Int32) {
65-
let session = TerminalSession(transport: terminalFactory())
69+
let transport = terminalFactory()
70+
transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground)
71+
let session = TerminalSession(transport: transport)
6672
session.onOutput = onOutput
6773
let processID = try session.startProcess(launch)
6874
terminalSessions.append(session)

0 commit comments

Comments
 (0)