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
227 changes: 155 additions & 72 deletions Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift

Large diffs are not rendered by default.

64 changes: 58 additions & 6 deletions Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ final class MatrixScreenSaverView: ScreenSaverView {
/// ``ScreenSyncCoordinator`` resolves a shared scene start time.
private var pendingSync = false

/// When `true`, a rain restart is deferred until the ``ScreenSyncCoordinator``
/// resolves a shared restart time across all displays.
private var pendingRestart = false

/// When `true`, a scene transition is deferred until the ``ScreenSyncCoordinator``
/// resolves a shared phase advance time across all displays.
private var pendingSceneAdvance = false

private var windowRect = NSRect.zero
private var titlebarRect = NSRect.zero
private var terminalRect = NSRect.zero
Expand Down Expand Up @@ -203,6 +211,8 @@ final class MatrixScreenSaverView: ScreenSaverView {
let wasActive = animationActive
animationActive = false
pendingSync = false
pendingRestart = false
pendingSceneAdvance = false
nativeRenderer.stop()
ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self))
releaseRenderingResources()
Expand All @@ -229,23 +239,65 @@ final class MatrixScreenSaverView: ScreenSaverView {
}
firstHiddenFrameTime = 0

// While waiting for multi-screen sync, poll the coordinator each
// frame. Once a shared start time is available, apply it and start
// the renderer so all displays begin simultaneously.
// While waiting for multi-screen sync (either initial startup or rain restart),
// poll the coordinator each frame. Once a shared start time is available,
// apply it and start the renderer so all displays begin simultaneously.
if pendingSync {
guard let startTime = ScreenSyncCoordinator.shared.resolvedStartTime() else {
let startTime: TimeInterval?
if pendingRestart {
startTime = ScreenSyncCoordinator.shared.resolvedRestartTime()
} else {
startTime = ScreenSyncCoordinator.shared.resolvedStartTime()
}
guard let time = startTime else {
return
}
pendingSync = false
nativeRenderer.overrideSceneStartTime = startTime
if pendingRestart {
ScreenSyncCoordinator.shared.finishRestart(ObjectIdentifier(self))
pendingRestart = false
NSLog("MatrixScreenSaver restart resolved — restarting rain (startTime=%.2f)", time)
} else {
NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", time)
}
nativeRenderer.overrideSceneStartTime = time
nativeRenderer.start()
NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", startTime)
}

// While waiting for a scene advance (Neo→Number, Number→Rain), poll the coordinator
// each frame. Once all displays are ready, advance to the next scene.
if pendingSceneAdvance {
guard let _ = ScreenSyncCoordinator.shared.resolvedPhaseAdvanceTime() else {
return
}
pendingSceneAdvance = false
ScreenSyncCoordinator.shared.finishPhaseAdvance(ObjectIdentifier(self))
nativeRenderer.advanceToNextScene()
NSLog("MatrixScreenSaver phase advance resolved — transitioning to next scene")
}

super.animateOneFrame()
if nativeRenderer.advance() {
needsDisplay = true
}

// After advance, check if the renderer has requested a restart
if nativeRenderer.consumeRestartRequest() {
ScreenSyncCoordinator.shared.requestRestart(ObjectIdentifier(self))
pendingRestart = true
pendingSync = true
}

// After advance, check if the renderer has completed a scene and needs to transition
if nativeRenderer.consumeNeoSceneDone() {
ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self))
pendingSceneAdvance = true
NSLog("MatrixScreenSaver Neo scene complete — requesting phase advance")
} else if nativeRenderer.consumeNumberSceneDone() {
ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self))
pendingSceneAdvance = true
NSLog("MatrixScreenSaver Number scene complete — requesting phase advance")
}
}

/// Releases renderer resources when the saver view is torn down.
Expand Down
154 changes: 124 additions & 30 deletions Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ final class NativeMatrixRenderer {
var twinkleEnabled = true
var diffuseEnabled = true
var rainDensity = 1.0
var frameRate = 25.0
var rainRunForever = true
var rainDurationSeconds = 300
var frameRate = 25.0
var errorRate = 1.0
var characters = ""
var neoMessageLines: [String] = NeoMessageScene.defaultLines
Expand All @@ -26,7 +28,9 @@ final class NativeMatrixRenderer {
twinkleEnabled: twinkleEnabled,
diffuseEnabled: diffuseEnabled,
rainDensity: max(rainDensity, MatrixRendererLimits.minimumRainDensity),
frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate),
rainRunForever: rainRunForever,
rainDurationSeconds: max(rainDurationSeconds, 1),
frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate),
errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate),
characters: characters,
neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines,
Expand Down Expand Up @@ -192,7 +196,6 @@ final class NativeMatrixRenderer {
private static let numberIntroCursorBlinkPeriod: TimeInterval = 0.5 // matches NeoMessageScene
private static let numberIntroCursorBlinkCount = 2 // 4 half-periods × 0.5 s ≈ 2 s
private static let numberIntroTypingInterval: TimeInterval = 1.0 / 40.0
private static let rainDuration: TimeInterval = 90.0 // how long rain runs before restarting
private static let numberIntroRainFrames = 160
private static let numberIntroBlackoutInterval: TimeInterval = 1.0
private static let numberIntroBlackoutRounds = 10
Expand Down Expand Up @@ -273,7 +276,11 @@ final class NativeMatrixRenderer {
private var now = 100
private var frameIndex = 0
private var activeScene: Scene = .numberIntro
private enum NumberIntroPhase {
private var rainProducing = true
private var restartRequested = false
private var neoSceneDoneRequested = false
private var numberSceneDoneRequested = false
private enum NumberIntroPhase {
case cursorBlink, typingFirstLine, pauseAfterFirst, secondLine, pauseAfterSecond, rain
}

Expand All @@ -300,7 +307,7 @@ final class NativeMatrixRenderer {
private var numberIntroSchedule = NumberIntroSchedule()
private var numberIntroBlackoutCount = 0
private var numberIntroBlackedColumns = Set<Int>()
private var rainStartTime: TimeInterval = 0 // unused; kept for future use
private var rainStartTime: TimeInterval = 0 // wall-clock time when current rain phase began

/// Returns the rendered cell content at the requested grid position.
subscript(row: Int, column: Int) -> RenderCell {
Expand Down Expand Up @@ -353,6 +360,7 @@ final class NativeMatrixRenderer {
/// Starts the renderer state for a new saver session.
func start() {
running = true
restartRequested = false
lastUpdateTime = Date.timeIntervalSinceReferenceDate
pendingSimulationSteps = 0.0
if columns > 0, rows > 0 {
Expand All @@ -366,6 +374,44 @@ final class NativeMatrixRenderer {
running = false
}

/// Returns whether a restart has been requested (by `stepRainFrame()` when
/// rain drains), then clears the flag. The View uses this to coordinate
/// the restart across all displays via the ``ScreenSyncCoordinator``.
func consumeRestartRequest() -> Bool {
defer { restartRequested = false }
return restartRequested
}

/// Returns whether the Neo scene has completed, then clears the flag.
/// The View uses this to coordinate the scene transition across all displays
/// via the ``ScreenSyncCoordinator``.
func consumeNeoSceneDone() -> Bool {
defer { neoSceneDoneRequested = false }
return neoSceneDoneRequested
}

/// Returns whether the Number scene has completed, then clears the flag.
/// The View uses this to coordinate the scene transition across all displays
/// via the ``ScreenSyncCoordinator``.
func consumeNumberSceneDone() -> Bool {
defer { numberSceneDoneRequested = false }
return numberSceneDoneRequested
}

/// Advances to the next scene in the sequence after a phase-advance barrier has resolved.
/// The View calls this after coordinating with other displays via the ``ScreenSyncCoordinator``.
func advanceToNextScene() {
switch activeScene {
case .neoMessage:
transitionFromNeoMessage()
case .numberIntro:
beginRainScene()
case .rainForever:
// Rain doesn't advance to another scene; it cycles via restart
break
}
}

/// Resizes the terminal grid and resets scene state to match it.
func resize(to size: TerminalSize) {
columns = max(size.columns, 1)
Expand Down Expand Up @@ -450,22 +496,60 @@ final class NativeMatrixRenderer {
}
}

/// Initializes the rain scene state.
private func beginRainScene(at wallClockTime: TimeInterval = Date.timeIntervalSinceReferenceDate) {
activeScene = .rainForever
rainStartTime = wallClockTime
rainProducing = true
}

/// Advances the continuous rain scene by one simulation step.
private func stepRainFrame() {
if frameIndex % spawnModulo == 0 {
addRandomThread()
}
frameIndex += 1
now += 1

layers[0].stepThreads(now: now, glyphProvider: randomGlyph)
layers[0].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph)
layers[1].stepThreads(now: now, glyphProvider: randomGlyph)
layers[1].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph)
layers[2].stepThreads(now: now, glyphProvider: randomGlyph)
private func stepRainFrame() {
// Check if we need to stop producing new lines (wind-down)
if !configuration.rainRunForever && rainProducing {
let elapsedSeconds = Date.timeIntervalSinceReferenceDate - rainStartTime
let durationSeconds = TimeInterval(configuration.rainDurationSeconds)
if elapsedSeconds >= durationSeconds {
rainProducing = false
}
}

// Only spawn new lines if rain is actively producing
if rainProducing && frameIndex % spawnModulo == 0 {
addRandomThread()
}
frameIndex += 1
now += 1

// Lines continue falling and stamping regardless; only spawning is gated
layers[0].stepThreads(now: now, glyphProvider: randomGlyph)
layers[0].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph)

layers[1].stepThreads(now: now, glyphProvider: randomGlyph)
layers[1].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph)

layers[2].stepThreads(now: now, glyphProvider: randomGlyph)
layers[2].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph)

constructRenderContent()

// If winding down and all layers are empty, restart the scene sequence
if !rainProducing && !configuration.rainRunForever {
let allLayersEmpty = layers.allSatisfy { layer in
layer.threads.isEmpty && layer.content.allSatisfy { $0.isBlank }
}
if allLayersEmpty && !restartRequested {
if configuration.skipSyncDelay {
// Preview context: self-restart in place
resetLayers()
overrideSceneStartTime = nil
beginSceneSequence()
} else {
// Engine context: hand off restart to View for coordinated timing
restartRequested = true
}
}
}
}

// MARK: - Number intro scene
Expand Down Expand Up @@ -624,7 +708,7 @@ final class NativeMatrixRenderer {
}

private func stepNumberRain(sceneTime: TimeInterval) {
while activeScene == .numberIntro {
while activeScene == .numberIntro && !numberSceneDoneRequested {
let expectedSceneTime = numberIntroSchedule.rainStart
+ Double(numberIntroBlackoutCount) * Self.numberIntroBlackoutInterval
if sceneTime >= expectedSceneTime {
Expand All @@ -633,7 +717,7 @@ final class NativeMatrixRenderer {
break
}
}
guard activeScene == .numberIntro else { return }
guard activeScene == .numberIntro && !numberSceneDoneRequested else { return }
scrollNumberRain()
self.now += 1
}
Expand Down Expand Up @@ -731,12 +815,17 @@ final class NativeMatrixRenderer {
backgroundLevel: 0, bold: false)
numberIntroBlackedColumns.insert(col)
}
visibleCountByRow[m] = (colStart..<colEnd).filter { numberIntroBlackedColumns.contains($0) }.count
numberIntroBlackoutCount += 1
if numberIntroBlackoutCount >= numberIntroSchedule.blackoutRounds.count {
activeScene = .rainForever
rainStartTime = Date.timeIntervalSinceReferenceDate
}
visibleCountByRow[m] = (colStart..<colEnd).filter { numberIntroBlackedColumns.contains($0) }.count
numberIntroBlackoutCount += 1
if numberIntroBlackoutCount >= numberIntroSchedule.blackoutRounds.count {
if configuration.skipSyncDelay {
// Preview context: self-transition immediately
beginRainScene()
} else {
// Engine context: signal completion and let View coordinate barrier
numberSceneDoneRequested = true
}
}
}

/// Returns a random palette level with power clamped to 0.2–0.6.
Expand Down Expand Up @@ -777,7 +866,13 @@ final class NativeMatrixRenderer {
setRow0(text: state.currentLine ?? "", typedCount: state.visibleCharCount, showCursor: false)
markAllRowsDirty()
if neoScene.phase == .done {
transitionFromNeoMessage()
if configuration.skipSyncDelay {
// Preview context: self-transition immediately
transitionFromNeoMessage()
} else {
// Engine context: signal completion and let View coordinate barrier
neoSceneDoneRequested = true
}
}
}

Expand All @@ -787,8 +882,7 @@ final class NativeMatrixRenderer {
activeScene = .numberIntro
initNumberIntroScene()
} else {
activeScene = .rainForever
rainStartTime = Date.timeIntervalSinceReferenceDate
beginRainScene()
stepRainFrame()
}
}
Expand Down Expand Up @@ -835,8 +929,8 @@ final class NativeMatrixRenderer {
activeScene = .numberIntro
initNumberIntroScene()
} else {
activeScene = .rainForever
rainStartTime = nowTime
let nowTime = Date.timeIntervalSinceReferenceDate
beginRainScene(at: nowTime)
stepRainFrame()
}
}
Expand Down
Loading
Loading