diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index ff244b8..ca23bfe 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -12,7 +12,9 @@ struct MatrixScreenSaverOptions: Equatable { static let characterWidth = "CharacterWidth" static let characterHeight = "CharacterHeight" static let rainDensity = "RainDensity" - static let frameRate = "FrameRate" + static let rainRunForever = "RainRunForever" + static let rainDurationSeconds = "RainDurationSeconds" + static let frameRate = "FrameRate" static let errorRate = "ErrorRate" static let characters = "Characters" static let scanLinesIntensity = "ScanLinesIntensity" @@ -27,8 +29,10 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultDiffuseEnabled = true static let defaultCharacterWidth = 16 static let defaultCharacterHeight = 30 - static let defaultRainDensity = 1.0 - static let defaultFrameRate = 25.0 + static let defaultRainDensity = 1.0 + static let defaultRainRunForever = true + static let defaultRainDurationSeconds = 300 + static let defaultFrameRate = 25.0 static let defaultErrorRate = 1.0 static let defaultCharacters = "" static let defaultScanLinesIntensity = 0.25 @@ -41,9 +45,11 @@ struct MatrixScreenSaverOptions: Equatable { static let minimumNeoMessageSpeedFactor = MatrixRendererLimits.minimumNeoMessageSpeedFactor static let minimumCharacterWidth = 1 static let minimumCharacterHeight = 1 - static let minNeoMessageLineCount = 1 - static let maxNeoMessageLineCount = 10 - static let maxNeoMessageLineLength = 256 + static let minNeoMessageLineCount = 1 + static let maxNeoMessageLineCount = 10 + static let maxNeoMessageLineLength = 256 + static let minimumRainDurationSeconds = 1 + static let maximumRainDurationSeconds = 9999 static let neoMessageSceneDescription = "Show the Neo message intro. On by default." static let neoMessageSpeedFactorDescription = "Speed multiplier for intro scene typing (Neo message and number scene). Default: 1.0." @@ -57,8 +63,10 @@ struct MatrixScreenSaverOptions: Equatable { (8, 15), (9, 17), (10, 19), (11, 21), (12, 23), (13, 24), (14, 26), (15, 28), (16, 30), (17, 32), (18, 34), (19, 36), (20, 38), (21, 40), (22, 42), (23, 44), (24, 46) ] static let defaultCharacterSizeIndex = 8 - static let rainDensityDescription = "Rain density multiplier. Default: 1.0." - static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." + static let rainDensityDescription = "Rain density multiplier. Default: 1.0." + static let rainRunForeverDescription = "When enabled (default), rain continuously spawns new falling lines. When disabled, rain stops starting new lines after the specified duration; lines already falling finish and fade out, then the full scene sequence restarts." + static let rainDurationSecondsDescription = "How long rain keeps starting new falling lines (1–9999 seconds) before it winds down. Default: 300 (5 minutes)." + static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." static let errorRateDescription = "Character change rate factor. Default: 1.0." static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default." static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." @@ -71,8 +79,10 @@ struct MatrixScreenSaverOptions: Equatable { var diffuseEnabled = defaultDiffuseEnabled var characterWidth = defaultCharacterWidth var characterHeight = defaultCharacterHeight - var rainDensity = defaultRainDensity - var frameRate = defaultFrameRate + var rainDensity = defaultRainDensity + var rainRunForever = defaultRainRunForever + var rainDurationSeconds = defaultRainDurationSeconds + var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters var scanLinesIntensity = defaultScanLinesIntensity @@ -87,7 +97,9 @@ struct MatrixScreenSaverOptions: Equatable { twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, rainDensity: rainDensity, - frameRate: frameRate, + rainRunForever: rainRunForever, + rainDurationSeconds: rainDurationSeconds, + frameRate: frameRate, errorRate: errorRate, characters: characters, neoMessageLines: neoMessageLines, @@ -114,8 +126,10 @@ struct MatrixScreenSaverOptions: Equatable { diffuseEnabled: diffuseEnabled, characterWidth: max(characterWidth, Self.minimumCharacterWidth), characterHeight: max(characterHeight, Self.minimumCharacterHeight), - rainDensity: max(rainDensity, Self.minimumRainDensity), - frameRate: min(max(frameRate, Self.minimumFrameRate), Self.maximumFrameRate), + rainDensity: max(rainDensity, Self.minimumRainDensity), + rainRunForever: rainRunForever, + rainDurationSeconds: min(max(rainDurationSeconds, Self.minimumRainDurationSeconds), Self.maximumRainDurationSeconds), + frameRate: min(max(frameRate, Self.minimumFrameRate), Self.maximumFrameRate), errorRate: max(errorRate, Self.minimumErrorRate), characters: { var seen = Set() @@ -147,8 +161,10 @@ struct MatrixScreenSaverOptions: Equatable { Keys.diffuseEnabled: defaultDiffuseEnabled, Keys.characterWidth: defaultCharacterWidth, Keys.characterHeight: defaultCharacterHeight, - Keys.rainDensity: defaultRainDensity, - Keys.frameRate: defaultFrameRate, + Keys.rainDensity: defaultRainDensity, + Keys.rainRunForever: defaultRainRunForever, + Keys.rainDurationSeconds: defaultRainDurationSeconds, + Keys.frameRate: defaultFrameRate, Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, Keys.scanLinesIntensity: defaultScanLinesIntensity, @@ -167,8 +183,10 @@ struct MatrixScreenSaverOptions: Equatable { diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), characterWidth: defaults.integer(forKey: Keys.characterWidth), characterHeight: defaults.integer(forKey: Keys.characterHeight), - rainDensity: defaults.double(forKey: Keys.rainDensity), - frameRate: defaults.double(forKey: Keys.frameRate), + rainDensity: defaults.double(forKey: Keys.rainDensity), + rainRunForever: defaults.bool(forKey: Keys.rainRunForever), + rainDurationSeconds: defaults.integer(forKey: Keys.rainDurationSeconds), + frameRate: defaults.double(forKey: Keys.frameRate), errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), @@ -187,8 +205,10 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) defaults.set(options.characterWidth, forKey: Keys.characterWidth) defaults.set(options.characterHeight, forKey: Keys.characterHeight) - defaults.set(options.rainDensity, forKey: Keys.rainDensity) - defaults.set(options.frameRate, forKey: Keys.frameRate) + defaults.set(options.rainDensity, forKey: Keys.rainDensity) + defaults.set(options.rainRunForever, forKey: Keys.rainRunForever) + defaults.set(options.rainDurationSeconds, forKey: Keys.rainDurationSeconds) + defaults.set(options.frameRate, forKey: Keys.frameRate) defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) @@ -200,25 +220,28 @@ struct MatrixScreenSaverOptions: Equatable { final class MatrixScreenSaverOptionsSheetController: NSObject, NSTextFieldDelegate, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { - private enum ValidationError: LocalizedError { - case neoMessageSpeedFactor - case rainDensity - case frameRate - case errorRate - - var errorDescription: String? { - switch self { - case .neoMessageSpeedFactor: - return "Neo message speed must be a positive number." - case .rainDensity: - return "Rain density must be a positive number." - case .frameRate: - return "Frame rate must be a positive number less than or equal to 1000." - case .errorRate: - return "Error rate must be a non-negative number." - } - } - } + private enum ValidationError: LocalizedError { + case neoMessageSpeedFactor + case rainDensity + case frameRate + case errorRate + case rainDurationSeconds + + var errorDescription: String? { + switch self { + case .neoMessageSpeedFactor: + return "Neo message speed must be a positive number." + case .rainDensity: + return "Rain density must be a positive number." + case .frameRate: + return "Frame rate must be a positive number less than or equal to 1000." + case .errorRate: + return "Error rate must be a non-negative number." + case .rainDurationSeconds: + return "Rain duration must be a positive integer (1–9999 seconds)." + } + } + } private weak var owner: MatrixScreenSaverView? var onClose: (() -> Void)? @@ -270,8 +293,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let pair = MatrixScreenSaverOptions.characterSizePairs[MatrixScreenSaverOptions.defaultCharacterSizeIndex] return NSTextField(labelWithString: "\(pair.width)×\(pair.height)") }() - private let rainDensityField = NSTextField(string: "") - private let frameRateField = NSTextField(string: "") + private let rainDensityField = NSTextField(string: "") + private let rainRunForeverCheckbox = NSButton(checkboxWithTitle: "Run forever", target: nil, action: nil) + private let rainDurationField = NSTextField(string: "") + private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") private var didClose = false @@ -306,7 +331,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, characterSizeSlider.doubleValue = Double(sizeIndex) let pair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" - rainDensityField.stringValue = Self.format(options.rainDensity) + rainDensityField.stringValue = Self.format(options.rainDensity) + rainRunForeverCheckbox.state = options.rainRunForever ? .on : .off + rainDurationField.stringValue = Self.format(options.rainDurationSeconds) + rainDurationField.isEnabled = !options.rainRunForever frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters @@ -357,16 +385,20 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, window.isReleasedWhenClosed = false window.delegate = self - characterSizeSlider.target = self - characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) - neoMessageSpeedFactorField.formatter = Self.numberFormatter - rainDensityField.formatter = Self.numberFormatter - frameRateField.formatter = Self.numberFormatter - errorRateField.formatter = Self.numberFormatter - neoMessageSpeedFactorField.delegate = self - rainDensityField.delegate = self - frameRateField.delegate = self - errorRateField.delegate = self + characterSizeSlider.target = self + characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) + neoMessageSpeedFactorField.formatter = Self.numberFormatter + rainDensityField.formatter = Self.numberFormatter + rainDurationField.formatter = Self.integerFormatter + frameRateField.formatter = Self.numberFormatter + errorRateField.formatter = Self.numberFormatter + neoMessageSpeedFactorField.delegate = self + rainDensityField.delegate = self + rainDurationField.delegate = self + frameRateField.delegate = self + errorRateField.delegate = self + rainRunForeverCheckbox.target = self + rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:)) rootStack.orientation = .vertical rootStack.alignment = .leading @@ -413,13 +445,14 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let numberGroup = makeGroupBox(title: "Number scene", views: [ makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription), ]) - let rainGroup = makeGroupBox(title: "Rain scene", views: [ - makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), - makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), - makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), - makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), - makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), - ]) + let rainGroup = makeGroupBox(title: "Rain scene", views: [ + makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), + makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), + makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), + makeRainDurationSection(), + makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), + makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), + ]) rootStack.addArrangedSubview(generalGroup) rootStack.addArrangedSubview(neoGroup) rootStack.addArrangedSubview(numberGroup) @@ -933,6 +966,45 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds the rain duration section with "Run forever" checkbox and optional seconds field. + private func makeRainDurationSection() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let checkboxRow = NSStackView() + checkboxRow.orientation = .horizontal + checkboxRow.spacing = 0 + checkboxRow.alignment = .centerY + rainRunForeverCheckbox.setButtonType(.switch) + checkboxRow.addArrangedSubview(rainRunForeverCheckbox) + + let secondsRow = NSStackView() + secondsRow.orientation = .horizontal + secondsRow.spacing = 12 + secondsRow.alignment = .centerY + + let secondsLabel = NSTextField(labelWithString: "Seconds") + secondsLabel.font = .systemFont(ofSize: 13, weight: .semibold) + secondsLabel.setContentHuggingPriority(.required, for: .horizontal) + + rainDurationField.alignment = .left + rainDurationField.controlSize = .regular + rainDurationField.translatesAutoresizingMaskIntoConstraints = false + rainDurationField.widthAnchor.constraint(equalToConstant: Self.fourDigitFieldWidth).isActive = true + + secondsRow.addArrangedSubview(secondsLabel) + secondsRow.addArrangedSubview(rainDurationField) + + stack.addArrangedSubview(checkboxRow) + stack.addArrangedSubview(secondsRow) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription)) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) + return stack + } + /// Builds the character size slider with a live size label. private func makeCharacterSizeSection() -> NSView { let stack = NSStackView() @@ -966,11 +1038,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } - @objc private func characterSizeSliderChanged(_ sender: NSSlider) { - let index = Int(sender.doubleValue.rounded()) - let pair = MatrixScreenSaverOptions.characterSizePairs[index] - characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" - } + @objc private func characterSizeSliderChanged(_ sender: NSSlider) { + let index = Int(sender.doubleValue.rounded()) + let pair = MatrixScreenSaverOptions.characterSizePairs[index] + characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" + } + + @objc private func rainRunForeverCheckboxChanged(_ sender: NSButton) { + let isRunningForever = sender.state == .on + rainDurationField.isEnabled = !isRunningForever + } /// Builds a titled group box containing a vertical stack of option sections. private func makeGroupBox(title: String, views: [NSView]) -> NSBox { @@ -1010,18 +1087,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private func validatedOptions() throws -> MatrixScreenSaverOptions { let sizeIndex = Int(characterSizeSlider.doubleValue.rounded()) let sizePair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] - let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) - let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) - let frameRate = try parseDouble(from: frameRateField, error: .frameRate) + let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) + let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) + let rainDurationSeconds = try parseInteger(from: rainDurationField, error: .rainDurationSeconds) + let frameRate = try parseDouble(from: frameRateField, error: .frameRate) let errorRate = try parseDouble(from: errorRateField, error: .errorRate) guard neoMessageSpeedFactor > 0 else { throw ValidationError.neoMessageSpeedFactor } - guard rainDensity > 0 else { - throw ValidationError.rainDensity - } - guard frameRate > 0, frameRate <= MatrixScreenSaverOptions.maximumFrameRate else { + guard rainDensity > 0 else { + throw ValidationError.rainDensity + } + guard rainDurationSeconds >= MatrixScreenSaverOptions.minimumRainDurationSeconds else { + throw ValidationError.rainDurationSeconds + } + guard frameRate > 0, frameRate <= MatrixScreenSaverOptions.maximumFrameRate else { throw ValidationError.frameRate } guard errorRate >= 0 else { @@ -1039,8 +1120,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, diffuseEnabled: diffuseCheckbox.state == .on, characterWidth: sizePair.width, characterHeight: sizePair.height, - rainDensity: rainDensity, - frameRate: frameRate, + rainDensity: rainDensity, + rainRunForever: rainRunForeverCheckbox.state == .on, + rainDurationSeconds: rainDurationSeconds, + frameRate: frameRate, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), scanLinesIntensity: scanLinesSlider.doubleValue / 100, diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index 35132d0..ad20a59 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -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 @@ -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() @@ -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. diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 140bcd3..72176da 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -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 @@ -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, @@ -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 @@ -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 } @@ -300,7 +307,7 @@ final class NativeMatrixRenderer { private var numberIntroSchedule = NumberIntroSchedule() private var numberIntroBlackoutCount = 0 private var numberIntroBlackedColumns = Set() - 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 { @@ -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 { @@ -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) @@ -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 @@ -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 { @@ -633,7 +717,7 @@ final class NativeMatrixRenderer { break } } - guard activeScene == .numberIntro else { return } + guard activeScene == .numberIntro && !numberSceneDoneRequested else { return } scrollNumberRain() self.now += 1 } @@ -731,12 +815,17 @@ final class NativeMatrixRenderer { backgroundLevel: 0, bold: false) numberIntroBlackedColumns.insert(col) } - visibleCountByRow[m] = (colStart..= numberIntroSchedule.blackoutRounds.count { - activeScene = .rainForever - rainStartTime = Date.timeIntervalSinceReferenceDate - } + visibleCountByRow[m] = (colStart..= 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. @@ -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 + } } } @@ -787,8 +882,7 @@ final class NativeMatrixRenderer { activeScene = .numberIntro initNumberIntroScene() } else { - activeScene = .rainForever - rainStartTime = Date.timeIntervalSinceReferenceDate + beginRainScene() stepRainFrame() } } @@ -835,8 +929,8 @@ final class NativeMatrixRenderer { activeScene = .numberIntro initNumberIntroScene() } else { - activeScene = .rainForever - rainStartTime = nowTime + let nowTime = Date.timeIntervalSinceReferenceDate + beginRainScene(at: nowTime) stepRainFrame() } } diff --git a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift index 70a7a47..851ea47 100644 --- a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift +++ b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift @@ -20,11 +20,35 @@ final class ScreenSyncCoordinator { /// Seconds after all instances register (or timeout) before scenes begin. static let startDelay: TimeInterval = 1.0 + /// Maximum seconds to wait for a stuck/hidden display during restart before resolving anyway. + static let restartSafetyTimeout: TimeInterval = 60.0 + private var expectedCount = 0 + private var maxExpectedCount = 0 private var registeredIDs: Set = [] private var firstRegistrationTime: TimeInterval = 0 private var resolvedTime: TimeInterval? + /// Instances that have requested a rain restart. + private var restartRequestedIDs: Set = [] + /// Resolved time for the next coordinated restart, once all displays are ready. + private var restartResolvedTime: TimeInterval? + /// Snapshot of when the first restart request arrived (for timeout fallback). + private var firstRestartRequestTime: TimeInterval = 0 + + /// Instances that have requested a phase advance (scene transition). + private var phaseAdvanceRequestedIDs: Set = [] + /// Resolved time for the phase advance barrier. + private var phaseAdvanceResolvedTime: TimeInterval? + /// Snapshot of when the first phase advance request arrived (for timeout). + private var firstPhaseAdvanceRequestTime: TimeInterval = 0 + + /// Maximum seconds to wait for all displays during a phase advance before resolving anyway. + static let phaseAdvanceTimeout: TimeInterval = 20.0 + + /// Delay before phase advance proceeds (0s since timing is pre-computed from shared seed). + static let phaseAdvanceDelay: TimeInterval = 0.0 + /// The number of currently registered instances. var registeredCount: Int { registeredIDs.count } @@ -43,14 +67,17 @@ final class ScreenSyncCoordinator { func register(_ id: ObjectIdentifier, expectedCount: Int) { if registeredIDs.isEmpty { self.expectedCount = max(expectedCount, 1) + self.maxExpectedCount = max(expectedCount, 1) firstRegistrationTime = Date.timeIntervalSinceReferenceDate resolvedTime = nil + } else { + // Update maxExpectedCount if a larger count is seen (handles delayed OS discovery). + maxExpectedCount = max(maxExpectedCount, expectedCount) } registeredIDs.insert(id) - if registeredIDs.count >= self.expectedCount && resolvedTime == nil { - resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay - } + // Try to resolve startup using live registered count vs max expected. + tryResolveStart() } /// Removes an instance from the coordinator. @@ -61,9 +88,46 @@ final class ScreenSyncCoordinator { /// - Parameter id: Unique identity of the unregistering view. func unregister(_ id: ObjectIdentifier) { registeredIDs.remove(id) + restartRequestedIDs.remove(id) + phaseAdvanceRequestedIDs.remove(id) if registeredIDs.isEmpty { resolvedTime = nil + restartResolvedTime = nil + phaseAdvanceResolvedTime = nil expectedCount = 0 + maxExpectedCount = 0 + } else { + // If a straggler detaches during startup, remaining displays must be able to resolve + // their startup without waiting for it. Check against max expected. + tryResolveStart() + // If a straggler detaches during restart, remaining displays must be able to resolve. + tryResolveRestart() + // If a straggler detaches during phase advance, remaining displays must be able to resolve. + tryResolvePhaseAdvance() + } + } + + /// Attempts to resolve startup time if all currently-registered displays + /// have registered. + /// + /// Called after each registration and when an instance unregisters, + /// so the coordinator can resolve as soon as all live displays have registered. + private func tryResolveStart() { + guard resolvedTime == nil, !registeredIDs.isEmpty else { return } + if registeredIDs.count >= maxExpectedCount { + resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay + } + } + + /// Attempts to resolve a restart time if all currently-registered displays + /// have requested a restart. + /// + /// This method is called after each restart request and when an instance unregisters, + /// so the coordinator can resolve as soon as all live displays have drained. + private func tryResolveRestart() { + guard restartResolvedTime == nil, !restartRequestedIDs.isEmpty else { return } + if restartRequestedIDs.count >= registeredIDs.count { + restartResolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay } } @@ -84,4 +148,134 @@ final class ScreenSyncCoordinator { } return nil } + + /// Requests a coordinated restart for the given instance. + /// + /// Call when the renderer's rain has fully drained and needs to cycle. + /// The coordinator accumulates restart requests from all displays, then + /// resolves a shared restart time once all have requested or timeout elapses. + /// Use ``resolvedRestartTime()`` to poll for the resolved time each frame, + /// and ``finishRestart(_:)`` once the instance has restarted. + /// + /// - Parameter id: Unique identity of the instance requesting restart. + func requestRestart(_ id: ObjectIdentifier) { + if restartRequestedIDs.isEmpty { + firstRestartRequestTime = Date.timeIntervalSinceReferenceDate + restartResolvedTime = nil + } + restartRequestedIDs.insert(id) + + // Check if all currently-registered displays have now requested a restart. + tryResolveRestart() + } + + /// Returns the shared restart time once all instances have requested + /// a restart, or after ``restartSafetyTimeout`` elapses. Returns `nil` + /// while still waiting. + func resolvedRestartTime() -> TimeInterval? { + if let time = restartResolvedTime { + return time + } + guard !restartRequestedIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstRestartRequestTime + if elapsed >= Self.restartSafetyTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.startDelay + restartResolvedTime = time + return time + } + return nil + } + + /// Marks the given instance as having completed its restart. + /// + /// Call after the instance has restarted its rain sequence. When the last + /// instance calls this, the restart barrier clears and is ready for the + /// next rain cycle. + /// + /// - Parameter id: Unique identity of the instance that restarted. + func finishRestart(_ id: ObjectIdentifier) { + restartRequestedIDs.remove(id) + if restartRequestedIDs.isEmpty { + restartResolvedTime = nil + } + } + + /// Requests a coordinated phase advance (scene transition) for the given instance. + /// + /// Call when a scene completes and needs to transition to the next scene. + /// The coordinator accumulates requests from all displays, then resolves a shared + /// time once all have requested or timeout elapses. + /// Use ``resolvedPhaseAdvanceTime()`` to poll for the resolved time each frame, + /// and ``finishPhaseAdvance(_:)`` once the instance has advanced. + /// + /// - Parameter id: Unique identity of the instance requesting phase advance. + func requestPhaseAdvance(_ id: ObjectIdentifier) { + if phaseAdvanceRequestedIDs.isEmpty { + firstPhaseAdvanceRequestTime = Date.timeIntervalSinceReferenceDate + phaseAdvanceResolvedTime = nil + } + phaseAdvanceRequestedIDs.insert(id) + + // Check if all currently-registered displays have now requested a phase advance. + tryResolvePhaseAdvance() + } + + /// Attempts to resolve phase advance time if all currently-registered displays + /// have requested a phase advance. + /// + /// Called after each phase advance request and when an instance unregisters. + private func tryResolvePhaseAdvance() { + guard phaseAdvanceResolvedTime == nil, !phaseAdvanceRequestedIDs.isEmpty else { return } + if phaseAdvanceRequestedIDs.count >= registeredIDs.count { + phaseAdvanceResolvedTime = Date.timeIntervalSinceReferenceDate + Self.phaseAdvanceDelay + } + } + + /// Returns the shared phase advance time once all instances have requested + /// a phase advance, or after ``phaseAdvanceTimeout`` elapses. Returns `nil` + /// while still waiting. + func resolvedPhaseAdvanceTime() -> TimeInterval? { + if let time = phaseAdvanceResolvedTime { + return time + } + guard !phaseAdvanceRequestedIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstPhaseAdvanceRequestTime + if elapsed >= Self.phaseAdvanceTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.phaseAdvanceDelay + phaseAdvanceResolvedTime = time + return time + } + return nil + } + + /// Marks the given instance as having completed its phase advance. + /// + /// Call after the instance has transitioned to the next scene. When the last + /// instance calls this, the phase advance barrier clears and is ready for the + /// next scene transition. + /// + /// - Parameter id: Unique identity of the instance that advanced. + func finishPhaseAdvance(_ id: ObjectIdentifier) { + phaseAdvanceRequestedIDs.remove(id) + if phaseAdvanceRequestedIDs.isEmpty { + phaseAdvanceResolvedTime = nil + } + } + + /// Resets the coordinator to a clean state (for testing only). + func _resetForTesting() { + expectedCount = 0 + maxExpectedCount = 0 + registeredIDs = [] + firstRegistrationTime = 0 + resolvedTime = nil + restartRequestedIDs = [] + restartResolvedTime = nil + firstRestartRequestTime = 0 + phaseAdvanceRequestedIDs = [] + phaseAdvanceResolvedTime = nil + firstPhaseAdvanceRequestTime = 0 + } } diff --git a/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift b/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift new file mode 100644 index 0000000..0e159d2 --- /dev/null +++ b/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Tests for ScreenSyncCoordinator restart barrier resolution logic. +func screenSyncCoordinatorTests() { + // Test 1: All displays requesting → immediate resolution + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: restart not resolved with 2/3 requesting") + + coordinator.requestRestart(id3) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: restart resolved when all 3 request") + + let now = Date.timeIntervalSinceReferenceDate + ok(resolved! > now && resolved! <= now + 2.0, "ScreenSyncCoordinator: startDelay applied to restart") + } + + // Test 2: Straggler unregister → remaining displays resolve + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id3 before unregister") + + coordinator.unregister(id3) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: resolves immediately after straggler unregisters") + ok(coordinator.registeredCount == 2, "ScreenSyncCoordinator: registered count dropped to 2") + } + + // Test 3: finishRestart re-arms the barrier + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: first cycle resolves") + + coordinator.finishRestart(id1) + coordinator.finishRestart(id2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: second cycle resolves (barrier re-armed)") + } + + // Test 4: Partial finish behavior + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: resolved with both requesting") + + coordinator.finishRestart(id1) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: still resolved while id2 unfinished") + + coordinator.finishRestart(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: clears after all finish") + } + + // Test 5: Unregister during pending restart + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id2") + + coordinator.unregister(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: unregister of sole pending requester allows resolution") + } + + // Test 6: Startup barrier fix — delayed registration with higher expected count + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + // First screen registers with low count (OS hasn't discovered all displays yet) + coordinator.register(id1, expectedCount: 2) + var startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup not resolved with only 1/2 registered") + + // Second screen registers with higher count (OS discovers more displays) + coordinator.register(id2, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup not resolved with 2/3 registered even though maxExpected=3") + + // Third screen completes the registration + coordinator.register(id3, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime != nil, "ScreenSyncCoordinator: startup resolves when maxExpected count is reached") + } + + // Test 7: Startup barrier fix — uses maxExpectedCount not first registration count + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + // First screen sees 2 displays, but actually there are 3 (delayed discovery) + coordinator.register(id1, expectedCount: 2) + var startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup waiting (1 < 2)") + + // Second screen discovers all 3 displays + coordinator.register(id2, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup waits for maxExpected (2 < 3 after second registration)") + + // Third screen confirms 3 displays + coordinator.register(id3, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime != nil, "ScreenSyncCoordinator: startup resolves when all maxExpected registers (3 >= 3)") + } + + // Test 8: Phase advance barrier — all displays requesting → immediate resolution + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved == nil, "ScreenSyncCoordinator: phase advance not resolved with 2/3 requesting") + + coordinator.requestPhaseAdvance(id3) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: phase advance resolved when all 3 request") + + let now = Date.timeIntervalSinceReferenceDate + ok(resolved! >= now - 0.1 && resolved! <= now + 0.1, "ScreenSyncCoordinator: phaseAdvanceDelay=0 applied (within 0.1s)") + } + + // Test 9: Phase advance barrier — straggler unregister → remaining displays resolve + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id3 before unregister") + + coordinator.unregister(id3) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: phase advance resolves immediately after straggler unregisters") + ok(coordinator.registeredCount == 2, "ScreenSyncCoordinator: registered count dropped to 2") + } + + // Test 10: Phase advance barrier — finishPhaseAdvance re-arms the barrier + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: first phase advance cycle resolves") + + coordinator.finishPhaseAdvance(id1) + coordinator.finishPhaseAdvance(id2) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: second phase advance cycle resolves (barrier re-armed)") + } +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift index 6bf26a9..014d59c 100644 --- a/Tests/MatrixScreenSaverTests/main.swift +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -16,6 +16,7 @@ func ok(_ condition: Bool, _ description: String, file: StaticString = #file, li } xorshift64Tests() +screenSyncCoordinatorTests() nativeMatrixRendererTests() print("1..\(testCount)") diff --git a/tests.sh b/tests.sh index f725aca..3307231 100755 --- a/tests.sh +++ b/tests.sh @@ -14,8 +14,10 @@ swiftc \ "$SRC/Xorshift64.swift" \ "$SRC/NeoMessageScene.swift" \ "$SRC/MatrixRendererLimits.swift" \ + "$SRC/ScreenSyncCoordinator.swift" \ "$SRC/NativeMatrixRenderer.swift" \ "$TESTS/Xorshift64Tests.swift" \ + "$TESTS/ScreenSyncCoordinatorTests.swift" \ "$TESTS/NativeMatrixRendererTests.swift" \ "$TESTS/main.swift" \ -o "$TEST_BIN"