diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml index 3d3f0d4..f1ad3cf 100644 --- a/.github/workflows/branch-policy.yml +++ b/.github/workflows/branch-policy.yml @@ -1,5 +1,8 @@ name: Branch policy +permissions: + contents: read + on: pull_request: branches: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..daca352 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +permissions: + contents: read + +on: + push: + branches: + - development + - 'feat/**' + - 'fix/**' + pull_request: + branches: + - development + - main + +jobs: + test: + name: Run tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: ./tests.sh diff --git a/AGENTS.md b/AGENTS.md index 630f01d..6802365 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,17 +18,126 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature - `ScreenSaverDefaults` for persisted options - Native renderer in `Sources/MatrixScreenSaver/` - Local preview host in `Tools/PreviewHost.swift` -- Shell scripts for build/install/preview: `build.sh`, `install.sh`, `preview.sh` +- Shell scripts for build/install/preview/test: `build.sh`, `install.sh`, `preview.sh`, `tests.sh` +- GitHub Actions for CI and releases + +## Repository layout + +### Source — `Sources/MatrixScreenSaver/` + +| File | Purpose | +|---|---| +| `MatrixScreenSaverView.swift` | Main `.saver` view — drawing, lifecycle, layout, options wiring | +| `NativeMatrixRenderer.swift` | Core in-process Matrix animation engine | +| `MatrixScreenSaverOptions.swift` | Persisted options model and native **Options…** configure sheet | +| `MatrixRendererLimits.swift` | Shared numeric clamps/limits (Foundation-only, no AppKit/ScreenSaver dependency) | +| `NeoMessageScene.swift` | "Neo" intro message state machine | +| `Xorshift64.swift` | Deterministic 64-bit RNG | +| `TerminalSupport.swift` | Shared terminal-like types: `TerminalSize`, `TerminalColor` | + +### Tools + +| File | Purpose | +|---|---| +| `Tools/PreviewHost.swift` | Standalone preview window host used by `./preview.sh` | + +### Tests — `Tests/MatrixScreenSaverTests/` + +| File | Purpose | +|---|---| +| `main.swift` | TAP harness entry point — `ok()` helper, orchestrates test files, prints plan | +| `Xorshift64Tests.swift` | Tests for `Xorshift64` RNG determinism | +| `NativeMatrixRendererTests.swift` | Tests for renderer `seedOffset` default and per-display divergence | + +### Scripts + +| Script | Purpose | +|---|---| +| `build.sh` | Builds `build/MatrixScreenSaver.saver` via `swiftc` | +| `tests.sh` | Compiles and runs the TAP test suite via `swiftc` (no Xcode required) | +| `install.sh` | Builds and installs to `~/Library/Screen Savers/`, kills `ScreenSaverEngine` | +| `preview.sh` | Builds and launches the saver in the preview host for fast iteration | +| `Scripts/install-saver.sh` | Shared installer (strips quarantine, copies with `ditto`, verifies hash) | + +### CI — `.github/workflows/` + +| Workflow | Trigger | Purpose | +|---|---|---| +| `tests.yml` | Push to `development`/`feat/**`/`fix/**`; PRs to `development` or `main` | Runs `./tests.sh` on `macos-latest` | +| `release.yml` | `workflow_dispatch` on `main`; push to `main` when `VERSION` changes | Bumps version, updates changelog, opens release PR; on merge publishes GitHub Release | + +### Resources + +- `Resources/Info.plist` — bundle metadata; version injected at build time from `VERSION` +- `Resources/Preview.png`, `Resources/Preview@2x.png` — saver thumbnail assets +- `VERSION` — single source of truth for the bundle and release version + +## Build + +```bash +./build.sh +``` + +Outputs `build/MatrixScreenSaver.saver`. Reads `VERSION`, compiles with `-O -parse-as-library -emit-library -Xlinker -bundle`, injects version into plist, ad-hoc codesigns. + +## Tests + +```bash +./tests.sh +``` + +Compiles a test binary with `swiftc` and runs it. Emits **TAP** (Test Anything Protocol) output — `ok N - description` / `not ok N - description` lines, then a `1..N` plan. Exits non-zero on failure. Command Line Tools only; no Xcode required. + +Tests cover: +- `Xorshift64` — same seed → same sequence; different seeds → different sequences +- `NativeMatrixRenderer` — `seedOffset` defaults to `0`; two renderers with different `seedOffset` values diverge after 60 frames + +## Code documentation + +Use Swift doc comments (`///`) for all public and internal declarations — types, properties, methods, and parameters. Follow the standard Swift/Xcode documentation style: + +```swift +/// A single falling column of Matrix characters. +/// +/// Each column tracks its own position, speed, and glyph sequence +/// so the renderer can update it independently each frame. +class MatrixColumn { + + /// The horizontal position of the column in grid units. + let x: Int + + /// Initializes a new column at the given grid position. + /// + /// - Parameters: + /// - x: Horizontal grid index for this column. + /// - speed: Fall speed in cells per frame. + init(x: Int, speed: Int) { … } + + /// Advances the column by one frame, updating character positions. + /// + /// - Returns: `true` if the column is still visible, `false` if it has scrolled off screen. + func tick() -> Bool { … } +} +``` + +- Use `///` (triple-slash) for doc comments; `//` for inline clarifications only. +- Include a `- Parameters:` block whenever a function takes non-obvious arguments. +- Include `- Returns:` and `- Throws:` blocks where applicable. +- Use `// MARK: -` to separate logical sections within a file. +- Do not comment self-evident code; focus comments on *why*, not *what*. ## Implementation notes - Keep the renderer native; do not reintroduce an external terminal or runtime wrapper. - Keep the options UI native AppKit. +- `MatrixRendererLimits.swift` is Foundation-only — do not import `AppKit` or `ScreenSaver` there; it must compile as part of the test binary without those frameworks. - Use `./preview.sh` for fast iteration and `./install.sh` for the real saver bundle. +- When adding a new source file that should be testable, add it to the `swiftc` invocation in `tests.sh`. +- When adding a new source file, also add it to the `swiftc` invocation in `build.sh`. ## Git commits -- Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...` +- Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...` - Use a short title line. - Add a few very concise bullet points for the meaningful changes. @@ -62,8 +171,9 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature - Merges to `main` must only come from `development` or `release/*` branches. - Merge to `main` only through a pull request. - Require approval before merging. +- The `development` branch is the default branch for new PRs. -Example: +Example commit: ```text feat: add character size options @@ -77,4 +187,5 @@ feat: add character size options - Trigger releases by running the **Release** GitHub Actions workflow (`workflow_dispatch`) — never manually. - The workflow bumps the version, updates `CHANGELOG.md`, and opens a `release/*` PR targeting `main`. -- Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release. +- Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release with a zipped `.saver` bundle attached. + diff --git a/README.md b/README.md index 982b13b..4422571 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ build/MatrixScreenSaver.saver `build.sh` reads the current version from `./VERSION` and writes it into the saver bundle metadata. +### Test + +```bash +./tests.sh +``` + +Compiles and runs the test suite using `swiftc`. No Xcode required — Command Line Tools are sufficient. + ### Preview without installing ```bash diff --git a/Sources/MatrixScreenSaver/MatrixRendererLimits.swift b/Sources/MatrixScreenSaver/MatrixRendererLimits.swift new file mode 100644 index 0000000..3527f97 --- /dev/null +++ b/Sources/MatrixScreenSaver/MatrixRendererLimits.swift @@ -0,0 +1,9 @@ +/// Validation limits shared between NativeMatrixRenderer and MatrixScreenSaverOptions. +/// Foundation-only so this file can be compiled without AppKit or ScreenSaver.framework. +enum MatrixRendererLimits { + static let minimumRainDensity: Double = 0.0001 + static let minimumFrameRate: Double = 0.0001 + static let maximumFrameRate: Double = 1000.0 + static let minimumErrorRate: Double = 0.0 + static let minimumNeoMessageSpeedFactor: Double = 0.0001 +} diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index f6f1b0e..6ad3293 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -25,8 +25,8 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultNumberSceneEnabled = true static let defaultTwinkleEnabled = true static let defaultDiffuseEnabled = true - static let defaultCharacterWidth = 11 - static let defaultCharacterHeight = 21 + static let defaultCharacterWidth = 16 + static let defaultCharacterHeight = 30 static let defaultRainDensity = 1.0 static let defaultFrameRate = 25.0 static let defaultErrorRate = 1.0 @@ -34,11 +34,11 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultScanLinesIntensity = 0.25 static let defaultScanLinesVertical = true - static let minimumRainDensity = 0.0001 - static let minimumFrameRate = 0.0001 - static let maximumFrameRate = 1000.0 - static let minimumErrorRate = 0.0 - static let minimumNeoMessageSpeedFactor = 0.0001 + static let minimumRainDensity = MatrixRendererLimits.minimumRainDensity + static let minimumFrameRate = MatrixRendererLimits.minimumFrameRate + static let maximumFrameRate = MatrixRendererLimits.maximumFrameRate + static let minimumErrorRate = MatrixRendererLimits.minimumErrorRate + static let minimumNeoMessageSpeedFactor = MatrixRendererLimits.minimumNeoMessageSpeedFactor static let minimumCharacterWidth = 1 static let minimumCharacterHeight = 1 static let minNeoMessageLineCount = 1 @@ -51,12 +51,12 @@ struct MatrixScreenSaverOptions: Equatable { static let numberSceneDescription = "Show the startup number scene before continuous rain. Turned on by default." static let diffuseDescription = "Turn on/off the glow effect. Turned on by default." static let twinkleDescription = "Turn on/off the twinkling effect. Turned on by default." - static let characterSizeDescription = "Character cell size in pixels. The default is 11×21." + static let characterSizeDescription = "Character cell size in pixels. The default is 16×30." static let characterSizePairs: [(width: Int, height: Int)] = [ - (4, 8), (5, 9), (6, 11), (7, 13), (8, 15), (9, 17), (10, 19), (11, 21), (12, 23), (13, 24), (14, 26), (15, 28), (16, 30), (17, 32), (18, 34) + (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 = 7 + static let defaultCharacterSizeIndex = 8 static let rainDensityDescription = "Set the factor for the density of rain drops. A positive number. The default is 1.0." static let frameRateDescription = "Set the frame rate per second. A positive number less than or equal to 1000. The default is 25." static let errorRateDescription = "Set the factor for the rate of character changes. A non-negative number. The default is 1.0." diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c853e9c..61b458f 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -78,6 +78,15 @@ final class MatrixScreenSaverView: ScreenSaverView { true } + /// A stable seed derived from this screen's CGDirectDisplayID. + /// Different physical displays return different values, giving each + /// NativeMatrixRenderer instance an independent animation sequence. + private var displaySeed: UInt64 { + let id = window?.screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? UInt32 ?? 0 + return UInt64(id) + } + override var hasConfigureSheet: Bool { true } @@ -170,6 +179,7 @@ final class MatrixScreenSaverView: ScreenSaverView { appendDebugLog("[\(debugIdentifier)] startAnimation bounds=\(NSStringFromRect(bounds))") updateScreenSaverLifecycleObservation() updateLayout() + nativeRenderer.seedOffset = displaySeed nativeRenderer.start() } diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index e466ef3..8a1c7bc 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -21,13 +21,13 @@ final class NativeMatrixRenderer { func sanitized() -> Configuration { Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, - neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixScreenSaverOptions.minimumNeoMessageSpeedFactor), + neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, - rainDensity: max(rainDensity, MatrixScreenSaverOptions.minimumRainDensity), - frameRate: min(max(frameRate, MatrixScreenSaverOptions.minimumFrameRate), MatrixScreenSaverOptions.maximumFrameRate), - errorRate: max(errorRate, MatrixScreenSaverOptions.minimumErrorRate), + rainDensity: max(rainDensity, MatrixRendererLimits.minimumRainDensity), + frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), + errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, skipSyncDelay: skipSyncDelay @@ -239,6 +239,10 @@ final class NativeMatrixRenderer { private(set) var rows = 0 private(set) var levelColors = NativeMatrixRenderer.palette + /// XORed into sceneSeed in beginSceneSequence so each physical display + /// gets an independent animation while preserving per-display determinism. + var seedOffset: UInt64 = 0 + private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool @@ -340,7 +344,7 @@ final class NativeMatrixRenderer { } var preferredAnimationTimeInterval: TimeInterval { - 1.0 / max(configuration.frameRate, MatrixScreenSaverOptions.minimumFrameRate) + 1.0 / max(configuration.frameRate, MatrixRendererLimits.minimumFrameRate) } /// Starts the renderer state for a new saver session. @@ -809,7 +813,7 @@ final class NativeMatrixRenderer { let syncWindow: TimeInterval = 10.0 sceneStartTime = floor(nowTime / syncWindow) * syncWindow + syncWindow } - sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) + sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) ^ seedOffset rainRNG = Xorshift64(seed: sceneSeed &+ 3) guard columns > 0, rows > 0 else { @@ -847,7 +851,7 @@ final class NativeMatrixRenderer { } private var spawnModulo: Int { - let density = max(configuration.rainDensity, MatrixScreenSaverOptions.minimumRainDensity) + let density = max(configuration.rainDensity, MatrixRendererLimits.minimumRainDensity) let baseSpawnModulo = (150.0 / density) / Double(max(columns, 1)) return max(1, Int(ceil(baseSpawnModulo * frameRateScale))) } diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift new file mode 100644 index 0000000..894582f --- /dev/null +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -0,0 +1,21 @@ +func nativeMatrixRendererTests() { + ok(NativeMatrixRenderer().seedOffset == 0, + "NativeMatrixRenderer: seedOffset defaults to 0") + + let r1: NativeMatrixRenderer = .init() + let r2: NativeMatrixRenderer = .init() + r1.seedOffset = 0 + r2.seedOffset = 0xDEAD_BEEF + var cfg = NativeMatrixRenderer.Configuration() + cfg.neoMessageSceneEnabled = false + cfg.numberSceneEnabled = false + r1.resize(to: TerminalSize(columns: 40, rows: 20)) + r2.resize(to: TerminalSize(columns: 40, rows: 20)) + r1.updateConfiguration(cfg) + r2.updateConfiguration(cfg) + r1.start() + r2.start() + for _ in 0..<60 { r1.advance(); r2.advance() } + let differ = (0..<20).contains { row in (0..<40).contains { col in r1[row, col] != r2[row, col] } } + ok(differ, "NativeMatrixRenderer: renderers with different seedOffsets diverge after 60 frames") +} diff --git a/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift b/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift new file mode 100644 index 0000000..b6e93ec --- /dev/null +++ b/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift @@ -0,0 +1,11 @@ +func xorshift64Tests() { + var rng1 = Xorshift64(seed: 42) + var rng2 = Xorshift64(seed: 42) + ok((0..<10).map { _ in rng1.next() } == (0..<10).map { _ in rng2.next() }, + "Xorshift64: identical seeds produce identical sequences") + + var rng3 = Xorshift64(seed: 42) + var rng4 = Xorshift64(seed: 99999) + ok((0..<10).map { _ in rng3.next() } != (0..<10).map { _ in rng4.next() }, + "Xorshift64: different seeds produce different sequences") +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift new file mode 100644 index 0000000..6bf26a9 --- /dev/null +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -0,0 +1,22 @@ +import Foundation + +// TAP harness — shared state used by all test files. +var testCount = 0 +var failCount = 0 + +func ok(_ condition: Bool, _ description: String, file: StaticString = #file, line: Int = #line) { + testCount += 1 + if condition { + print("ok \(testCount) - \(description)") + } else { + print("not ok \(testCount) - \(description)") + print(" # \(file):\(line)") + failCount += 1 + } +} + +xorshift64Tests() +nativeMatrixRendererTests() + +print("1..\(testCount)") +exit(failCount == 0 ? 0 : 1) diff --git a/build.sh b/build.sh index aafd61c..e6c6e30 100755 --- a/build.sh +++ b/build.sh @@ -36,6 +36,7 @@ swiftc \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ + "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ "$SOURCE_DIR/MatrixScreenSaverView.swift" diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..f725aca --- /dev/null +++ b/tests.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +SRC="$ROOT/Sources/MatrixScreenSaver" +TESTS="$ROOT/Tests/MatrixScreenSaverTests" +TEST_BIN=/tmp/matrix_screensaver_tests + +swiftc \ + -framework Foundation \ + -framework AppKit \ + -framework ScreenSaver \ + "$SRC/TerminalSupport.swift" \ + "$SRC/Xorshift64.swift" \ + "$SRC/NeoMessageScene.swift" \ + "$SRC/MatrixRendererLimits.swift" \ + "$SRC/NativeMatrixRenderer.swift" \ + "$TESTS/Xorshift64Tests.swift" \ + "$TESTS/NativeMatrixRendererTests.swift" \ + "$TESTS/main.swift" \ + -o "$TEST_BIN" + +"$TEST_BIN"