Skip to content
/ TUIkit Public

Commit 4d3eb28

Browse files
committed
fix: hotfix palette surface backgrounds and macOS-only unfair lock
1 parent f58ee24 commit 4d3eb28

6 files changed

Lines changed: 359 additions & 15 deletions

File tree

Sources/TUIkit/App/RenderLoop.swift

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ private struct EnvironmentSnapshot: Equatable {
3030
}
3131
}
3232

33+
/// ANSI background codes for each render surface in a frame.
34+
///
35+
/// Keeping these grouped avoids accidentally rendering every surface
36+
/// with `palette.background` and ignoring palette-specific tokens like
37+
/// `statusBarBackground`.
38+
internal struct RenderBackgroundCodes: Equatable {
39+
/// Main content area background code.
40+
let content: String
41+
42+
/// App header background code.
43+
let appHeader: String
44+
45+
/// Status bar background code.
46+
let statusBar: String
47+
48+
init(palette: any Palette) {
49+
self.content = ANSIRenderer.backgroundCode(for: palette.background)
50+
self.appHeader = ANSIRenderer.backgroundCode(for: palette.appHeaderBackground)
51+
self.statusBar = ANSIRenderer.backgroundCode(for: palette.statusBarBackground)
52+
}
53+
}
54+
3355
// MARK: - Render Loop
3456

3557
/// Manages the full rendering pipeline for each frame.
@@ -178,9 +200,13 @@ extension RenderLoop {
178200
var environment = buildEnvironment()
179201
environment.pulsePhase = pulsePhase
180202
environment.cursorTimer = cursorTimer
181-
invalidateCacheIfEnvironmentChanged(environment: environment)
182203

183204
let scene = evaluateAppBody(environment: environment)
205+
if let paletteOverrideScene = scene as? any RootPaletteOverrideProvidingScene,
206+
let paletteOverride = paletteOverrideScene.rootPaletteOverride() {
207+
environment.palette = paletteOverride
208+
}
209+
invalidateCacheIfEnvironmentChanged(environment: environment)
184210

185211
// Determine header height. On the first frame, we perform a measurement
186212
// pass to discover the actual header height before outputting anything.
@@ -329,15 +355,15 @@ private extension RenderLoop {
329355
statusBarHeight: Int,
330356
headerHeight: Int
331357
) {
332-
let bgCode = ANSIRenderer.backgroundCode(for: environment.palette.background)
358+
let backgroundCodes = RenderBackgroundCodes(palette: environment.palette)
333359
let reset = ANSIRenderer.reset
334360
let contentHeight = terminalHeight - statusBarHeight - headerHeight
335361

336362
let outputLines = diffWriter.buildOutputLines(
337363
buffer: buffer,
338364
terminalWidth: terminalWidth,
339365
terminalHeight: contentHeight,
340-
bgCode: bgCode,
366+
bgCode: backgroundCodes.content,
341367
reset: reset
342368
)
343369

@@ -347,7 +373,8 @@ private extension RenderLoop {
347373
renderAppHeader(
348374
atRow: 1,
349375
terminalWidth: terminalWidth,
350-
bgCode: bgCode,
376+
environment: environment,
377+
bgCode: backgroundCodes.appHeader,
351378
reset: reset
352379
)
353380
}
@@ -362,7 +389,8 @@ private extension RenderLoop {
362389
renderStatusBar(
363390
atRow: terminalHeight - statusBarHeight + 1,
364391
terminalWidth: terminalWidth,
365-
bgCode: bgCode,
392+
environment: environment,
393+
bgCode: backgroundCodes.statusBar,
366394
reset: reset
367395
)
368396
}
@@ -406,10 +434,15 @@ private extension RenderLoop {
406434
}
407435

408436
/// Renders the app header at the specified terminal row.
409-
func renderAppHeader(atRow row: Int, terminalWidth: Int, bgCode: String, reset: String) {
437+
func renderAppHeader(
438+
atRow row: Int,
439+
terminalWidth: Int,
440+
environment: EnvironmentValues,
441+
bgCode: String,
442+
reset: String
443+
) {
410444
guard let contentBuffer = appHeader.contentBuffer else { return }
411445

412-
let environment = buildEnvironment()
413446
let headerView = AppHeader(contentBuffer: contentBuffer)
414447

415448
let context = RenderContext(
@@ -431,8 +464,13 @@ private extension RenderLoop {
431464
}
432465

433466
/// Renders the status bar at the specified terminal row.
434-
func renderStatusBar(atRow row: Int, terminalWidth: Int, bgCode: String, reset: String) {
435-
let environment = buildEnvironment()
467+
func renderStatusBar(
468+
atRow row: Int,
469+
terminalWidth: Int,
470+
environment: EnvironmentValues,
471+
bgCode: String,
472+
reset: String
473+
) {
436474
let palette = environment.palette
437475

438476
let highlightColor =
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// 🖥️ TUIKit — Terminal UI Kit for Swift
2+
// Scene+PaletteOverride.swift
3+
//
4+
// Created by LAYERED.work
5+
// License: MIT
6+
7+
// MARK: - Root Palette Discovery
8+
9+
/// A scene that can provide a root-level palette override.
10+
///
11+
/// `RenderLoop` uses this to keep out-of-tree surfaces (status bar, app header)
12+
/// aligned with `.palette(...)` applied at the root view level.
13+
@MainActor
14+
internal protocol RootPaletteOverrideProvidingScene: Scene {
15+
/// Returns the root palette override, if present.
16+
func rootPaletteOverride() -> (any Palette)?
17+
}
18+
19+
/// Type-erased access to `EnvironmentModifier` internals.
20+
///
21+
/// This is intentionally limited to root modifier chain discovery.
22+
@MainActor
23+
private protocol AnyEnvironmentModifierNode {
24+
var anyEnvironmentKeyPath: AnyKeyPath { get }
25+
var anyEnvironmentValue: Any { get }
26+
var anyEnvironmentContent: Any { get }
27+
}
28+
29+
@MainActor
30+
extension EnvironmentModifier: AnyEnvironmentModifierNode {
31+
fileprivate var anyEnvironmentKeyPath: AnyKeyPath { keyPath }
32+
fileprivate var anyEnvironmentValue: Any { value }
33+
fileprivate var anyEnvironmentContent: Any { content }
34+
}
35+
36+
@MainActor
37+
extension WindowGroup: RootPaletteOverrideProvidingScene {
38+
func rootPaletteOverride() -> (any Palette)? {
39+
var current: Any = content
40+
41+
while let modifier = current as? any AnyEnvironmentModifierNode {
42+
if modifier.anyEnvironmentKeyPath == \EnvironmentValues.palette,
43+
let palette = modifier.anyEnvironmentValue as? any Palette {
44+
return palette
45+
}
46+
current = modifier.anyEnvironmentContent
47+
}
48+
49+
return nil
50+
}
51+
}

Sources/TUIkit/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.1
1+
0.5.2

Sources/TUIkitCore/Concurrency/Lock.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@
66

77
import Foundation
88

9-
#if canImport(os)
9+
#if canImport(os) && os(macOS)
1010
import os
1111
#endif
1212

1313
/// A cross-platform lock wrapper that uses the best available implementation.
1414
///
15-
/// On Apple platforms, uses `OSAllocatedUnfairLock` for optimal performance
16-
/// (unfair lock with no syscall in the uncontended case). On Linux, falls back
17-
/// to `NSLock`.
15+
/// On macOS, uses `OSAllocatedUnfairLock` for optimal performance
16+
/// (unfair lock with no syscall in the uncontended case). On Linux and other
17+
/// non-macOS platforms, falls back to `NSLock`.
1818
///
1919
/// This type is `@unchecked Sendable` because the underlying lock implementations
2020
/// are thread-safe by design.
2121
public final class Lock<State: Sendable>: @unchecked Sendable {
22-
#if canImport(os)
22+
#if canImport(os) && os(macOS)
2323
private let _lock: OSAllocatedUnfairLock<State>
2424

2525
/// Creates a lock with the given initial state.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// 🖥️ TUIKit — Terminal UI Kit for Swift
2+
// RenderLoopPaletteIntegrationTests.swift
3+
//
4+
// Created by LAYERED.work
5+
// License: MIT
6+
7+
import Testing
8+
9+
@testable import TUIkit
10+
11+
private struct DistinctBackgroundPalette: Palette {
12+
let id = "distinct-bg"
13+
let name = "Distinct BG"
14+
let background = Color.red
15+
let foreground = Color.white
16+
let accent = Color.cyan
17+
let success = Color.green
18+
let warning = Color.yellow
19+
let error = Color.magenta
20+
let info = Color.blue
21+
let border = Color.brightBlack
22+
let statusBarBackground = Color.green
23+
let appHeaderBackground = Color.blue
24+
}
25+
26+
@MainActor
27+
@Suite("Render Loop Palette Integration Tests")
28+
struct RenderLoopPaletteIntegrationTests {
29+
30+
@Test("RenderBackgroundCodes map each surface to the correct palette token")
31+
func renderBackgroundCodesUseSurfaceTokens() {
32+
let palette = DistinctBackgroundPalette()
33+
let codes = RenderBackgroundCodes(palette: palette)
34+
35+
#expect(codes.content == ANSIRenderer.backgroundCode(for: palette.background))
36+
#expect(codes.appHeader == ANSIRenderer.backgroundCode(for: palette.appHeaderBackground))
37+
#expect(codes.statusBar == ANSIRenderer.backgroundCode(for: palette.statusBarBackground))
38+
#expect(codes.content != codes.statusBar)
39+
#expect(codes.content != codes.appHeader)
40+
}
41+
42+
@Test("WindowGroup root palette override is discovered for built-in palettes")
43+
func discoversSystemPaletteOverride() {
44+
let scene = WindowGroup {
45+
Text("Hello")
46+
.palette(SystemPalette(.blue))
47+
}
48+
49+
#expect(scene.rootPaletteOverride()?.id == "blue")
50+
}
51+
52+
@Test("WindowGroup root palette override supports custom palettes")
53+
func discoversCustomPaletteOverride() {
54+
let palette = DistinctBackgroundPalette()
55+
let scene = WindowGroup {
56+
Text("Hello")
57+
.palette(palette)
58+
}
59+
60+
#expect(scene.rootPaletteOverride()?.id == palette.id)
61+
#expect(scene.rootPaletteOverride()?.statusBarBackground == palette.statusBarBackground)
62+
}
63+
64+
@Test("WindowGroup root palette override prefers the outermost palette")
65+
func outermostPaletteWins() {
66+
let scene = WindowGroup {
67+
VStack {
68+
Text("Nested")
69+
.palette(SystemPalette(.blue))
70+
}
71+
.palette(SystemPalette(.amber))
72+
}
73+
74+
#expect(scene.rootPaletteOverride()?.id == "amber")
75+
}
76+
77+
@Test("WindowGroup without root palette override returns nil")
78+
func noRootOverrideReturnsNil() {
79+
let scene = WindowGroup {
80+
VStack {
81+
Text("Nested")
82+
.palette(SystemPalette(.blue))
83+
}
84+
}
85+
86+
#expect(scene.rootPaletteOverride() == nil)
87+
}
88+
}

0 commit comments

Comments
 (0)