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
3 changes: 3 additions & 0 deletions .github/workflows/branch-policy.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Branch policy

permissions:
contents: read

on:
pull_request:
branches:
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
119 changes: 115 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.

8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Sources/MatrixScreenSaver/MatrixRendererLimits.swift
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 10 additions & 10 deletions Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,20 @@ 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
static let defaultCharacters = ""
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
Expand All @@ -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."
Expand Down
10 changes: 10 additions & 0 deletions Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -170,6 +179,7 @@ final class MatrixScreenSaverView: ScreenSaverView {
appendDebugLog("[\(debugIdentifier)] startAnimation bounds=\(NSStringFromRect(bounds))")
updateScreenSaverLifecycleObservation()
updateLayout()
nativeRenderer.seedOffset = displaySeed
nativeRenderer.start()
}

Expand Down
18 changes: 11 additions & 7 deletions Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)))
}
Expand Down
21 changes: 21 additions & 0 deletions Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
11 changes: 11 additions & 0 deletions Tests/MatrixScreenSaverTests/Xorshift64Tests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
22 changes: 22 additions & 0 deletions Tests/MatrixScreenSaverTests/main.swift
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading