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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0




## [0.4.0] - 2026-10-27

### Bug Fixes

- speed up wallpaper startup and restore desktop images (#108)
- compact options and add coffee links (#107)

## [0.3.0] - 2026-10-23

### Features
Expand Down
9 changes: 6 additions & 3 deletions Homebrew/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ brew style "$PWD/Homebrew/Casks/matrix-screen-saver.rb"

```bash
brew trust --cask patrickschaper/tap/matrix-screen-saver
brew install --cask patrickschaper/tap/matrix-screen-saver
brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app
```

The command launches the wallpaper app after installation succeeds, using Homebrew's
default `/Applications` location. If you use a custom `--appdir`, adjust the `open` path.

The fully qualified install trusts this cask, not the entire tap. Explicit
`brew trust --cask` also permits subsequent installs using the short name.

Expand All @@ -40,8 +43,8 @@ After installation, open **System Settings > Wallpaper > Screen Saver** and
select **MatrixScreenSaver** manually. Modern Homebrew's sandbox blocks opening
Settings, so automatic opening was intentionally removed. The cask never selects
the saver, launches the wallpaper on a fresh install, enables login startup, or opens
a browser. Open **MatrixWallpaper** from Homebrew's configured `appdir` (normally
`/Applications`) yourself, then use its single **Launch at Login** checkbox if wanted.
a browser. The command above launches **MatrixWallpaper** from your shell after Homebrew
finishes; use its single **Launch at Login** checkbox if wanted.
Homebrew may reopen an already-running app following an upgrade. Successful install,
upgrade, and reinstall print:

Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ The screen saver also comes with an app replacing your wallpaper with the animat

```bash
brew trust --cask patrickschaper/tap/matrix-screen-saver
brew install --cask patrickschaper/tap/matrix-screen-saver
brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app
```

- Wallpaper: open **MatrixWallpaper** in Applications.
- Saver options: **System Settings → Wallpaper → Screen Saver → MatrixScreenSaver → Options…**.
- Wallpaper: open **MatrixWallpaper** in Applications. options available through menubar icon.
- Screen saver options: **System Settings → Wallpaper → Screen Saver → MatrixScreenSaver → Options…**.

## Buy me a coffee

Expand Down Expand Up @@ -165,6 +165,17 @@ shares the wallpaper's animation. Uncheck it to let the saver animate independen
The choice survives app restarts and saver reinstallation; opening the menu refreshes
installation detection. This does not change visual options or synchronization between monitors.

While Matrix Wallpaper runs, it saves each display's desktop image and display options,
then sets the underlying wallpaper to plain black. **Stop** and **Quit Matrix Wallpaper**
restore those settings. Temporary pauses and animation restarts keep the original backup.
If you choose a different wallpaper while Matrix runs, that newer choice is preserved.

Backups are stored in `~/Library/Application Support/MatrixWallpaper/DesktopWallpaper/`.
After a crash or force-quit, reopen Matrix Wallpaper and choose **Stop** to restore the desktop.
Disconnected displays and failed restores keep their backups for a later retry.
Exact restoration is supported for static images; macOS's public API cannot preserve every
dynamic/aerial wallpaper, rotating collection, or separate Space configuration.

### Origin

This project started as an attempt to wrap `cxxmatrix` inside a macOS screen saver.
Expand Down
124 changes: 65 additions & 59 deletions Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,

private let window: NSWindow
private let rootStack = NSStackView()
private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Activate", target: nil, action: nil)
private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Show Neo message scene", target: nil, action: nil)
private let neoMessageSpeedFactorField = NSTextField(string: "")
private let linesTableView = NSTableView()
private let linesScrollView = NSScrollView()
Expand All @@ -275,7 +275,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
private static let removeColumnID = NSUserInterfaceItemIdentifier("remove")
private var pendingLines: [String] = MatrixScreenSaverOptions.defaultNeoMessageLines
private var linesEditorWindow: NSWindow?
private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Activate", target: nil, action: nil)
private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show number scene", target: nil, action: nil)
private let twinkleCheckbox = NSButton(checkboxWithTitle: "Twinkling effect", target: nil, action: nil)
private let diffuseCheckbox = NSButton(checkboxWithTitle: "Glow effect", target: nil, action: nil)
private let scanLinesSlider: NSSlider = {
Expand Down Expand Up @@ -384,6 +384,13 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
}
}

/// Opens the project's donation page in the default browser.
@objc private func openDonationPage(_ sender: Any?) {
if let url = URL(string: "https://www.buymeacoffee.com/yesman82") {
NSWorkspace.shared.open(url)
}
}

/// Restores the controls to the default option values.
@objc private func resetToDefaults(_ sender: Any?) {
pendingLines = MatrixScreenSaverOptions.defaultNeoMessageLines
Expand Down Expand Up @@ -417,62 +424,42 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
rootStack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
rootStack.translatesAutoresizingMaskIntoConstraints = false

// Header row: title left, About link right
let headerContainer = NSView()
headerContainer.translatesAutoresizingMaskIntoConstraints = false

let titleLabel = NSTextField(labelWithString: "macOS Matrix Screen Saver")
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.translatesAutoresizingMaskIntoConstraints = false

let aboutButton = NSButton(title: "About", target: self, action: #selector(openRepository(_:)))
aboutButton.bezelStyle = .inline
aboutButton.isBordered = false
aboutButton.contentTintColor = .linkColor
aboutButton.font = .systemFont(ofSize: 13)
aboutButton.translatesAutoresizingMaskIntoConstraints = false

headerContainer.addSubview(titleLabel)
headerContainer.addSubview(aboutButton)
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor),
titleLabel.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
aboutButton.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor),
aboutButton.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor),
headerContainer.heightAnchor.constraint(equalTo: titleLabel.heightAnchor),
])
rootStack.addArrangedSubview(headerContainer)

let generalGroup = makeGroupBox(title: "General", views: [
let tabs = NSTabView()
tabs.translatesAutoresizingMaskIntoConstraints = false
tabs.addTabViewItem(makeOptionsTab(title: "General", views: [
makeCharacterSizeSection(),
makeScanLinesSection(),
makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: MatrixScreenSaverOptions.neoMessageSpeedFactorDescription),
makeNumericSection(title: "Frame rate", field: frameRateField, description: MatrixScreenSaverOptions.frameRateDescription),
])
let neoGroup = makeGroupBox(title: "Neo message scene", views: [
]))
tabs.addTabViewItem(makeOptionsTab(title: "Intro scenes", views: [
makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription),
makeNeoMessageLinesButtonRow(),
])
let numberGroup = makeGroupBox(title: "Number scene", views: [
makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription),
])
let rainGroup = makeGroupBox(title: "Rain scene", views: [
makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: MatrixScreenSaverOptions.neoMessageSpeedFactorDescription),
]))
tabs.addTabViewItem(makeOptionsTab(title: "Rain", 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)
rootStack.addArrangedSubview(rainGroup)
]))
rootStack.addArrangedSubview(tabs)

let groupWidth = -(rootStack.edgeInsets.left + rootStack.edgeInsets.right)
for group in [generalGroup, neoGroup, numberGroup, rainGroup] {
group.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth).isActive = true
}
// Keep every page the same size so switching tabs never moves the dialog buttons.
NSLayoutConstraint.activate([
tabs.widthAnchor.constraint(equalToConstant: 520),
tabs.heightAnchor.constraint(equalToConstant: 460),
tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth),
])

let buttons = NSStackView()
buttons.orientation = .horizontal
Expand All @@ -495,26 +482,30 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
footer.distribution = .fill
footer.translatesAutoresizingMaskIntoConstraints = false

let spacerLeft = NSView()
spacerLeft.translatesAutoresizingMaskIntoConstraints = false
spacerLeft.setContentHuggingPriority(.defaultLow, for: .horizontal)
spacerLeft.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

let versionString = Bundle(for: MatrixScreenSaverOptionsSheetController.self)
.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let versionLabel = NSTextField(labelWithString: versionString.isEmpty ? "" : "v\(versionString)")
versionLabel.font = .systemFont(ofSize: 11)
versionLabel.textColor = .tertiaryLabelColor
versionLabel.setContentHuggingPriority(.required, for: .horizontal)

let coffeeButton = NSButton(title: "Buy me a coffee ☕", target: self, action: #selector(openDonationPage(_:)))
coffeeButton.bezelStyle = .inline
coffeeButton.isBordered = false
coffeeButton.controlSize = .small
coffeeButton.contentTintColor = .linkColor
coffeeButton.font = aboutButton.font
coffeeButton.setContentHuggingPriority(.required, for: .horizontal)

let spacerRight = NSView()
spacerRight.translatesAutoresizingMaskIntoConstraints = false
spacerRight.setContentHuggingPriority(.defaultLow, for: .horizontal)
spacerRight.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

footer.addArrangedSubview(resetButton)
footer.addArrangedSubview(spacerLeft)
footer.addArrangedSubview(aboutButton)
footer.addArrangedSubview(versionLabel)
footer.addArrangedSubview(coffeeButton)
footer.addArrangedSubview(spacerRight)
footer.addArrangedSubview(buttons)

Expand All @@ -531,8 +522,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
NSLayoutConstraint.activate([
rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
rootStack.topAnchor.constraint(equalTo: contentView.topAnchor),
rootStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor),
rootStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])

sizeWindowToFitContent()
Expand All @@ -554,6 +545,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let label = NSTextField(labelWithString: "Lines")
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.setContentHuggingPriority(.required, for: .horizontal)
label.widthAnchor.constraint(equalToConstant: 100).isActive = true

let editButton = NSButton(title: "Edit…", target: self, action: #selector(editLines(_:)))
editButton.setContentHuggingPriority(.required, for: .horizontal)
Expand Down Expand Up @@ -879,6 +871,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let label = NSTextField(labelWithString: "Scan lines")
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.setContentHuggingPriority(.required, for: .horizontal)
label.widthAnchor.constraint(equalToConstant: 100).isActive = true
scanLinesSlider.setAccessibilityLabel("Scan line intensity")
scanLinesDirectionPopup.setAccessibilityLabel("Scan line direction")

scanLinesSlider.target = self
scanLinesSlider.action = #selector(scanLinesSliderChanged(_:))
Expand Down Expand Up @@ -933,6 +928,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let label = NSTextField(labelWithString: title)
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.setContentHuggingPriority(.required, for: .horizontal)
label.widthAnchor.constraint(equalToConstant: 100).isActive = true
field.setAccessibilityLabel(title)

field.alignment = .left
field.controlSize = .regular
Expand Down Expand Up @@ -963,6 +960,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let label = NSTextField(labelWithString: title)
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.setContentHuggingPriority(.required, for: .horizontal)
label.widthAnchor.constraint(equalToConstant: 100).isActive = true
field.setAccessibilityLabel(title)

field.alignment = .left
field.controlSize = .regular
Expand Down Expand Up @@ -1000,6 +999,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let secondsLabel = NSTextField(labelWithString: "Seconds")
secondsLabel.font = .systemFont(ofSize: 13, weight: .semibold)
secondsLabel.setContentHuggingPriority(.required, for: .horizontal)
secondsLabel.widthAnchor.constraint(equalToConstant: 100).isActive = true
rainDurationField.setAccessibilityLabel("Rain duration in seconds")

rainDurationField.alignment = .left
rainDurationField.controlSize = .regular
Expand Down Expand Up @@ -1032,6 +1033,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
let titleLabel = NSTextField(labelWithString: "Character size")
titleLabel.font = .systemFont(ofSize: 13, weight: .semibold)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
titleLabel.widthAnchor.constraint(equalToConstant: 100).isActive = true
characterSizeSlider.setAccessibilityLabel("Character size")

characterSizeSlider.translatesAutoresizingMaskIntoConstraints = false
characterSizeSlider.widthAnchor.constraint(equalToConstant: 120).isActive = true
Expand Down Expand Up @@ -1060,37 +1063,40 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
rainDurationField.isEnabled = !isRunningForever
}

/// Builds a titled group box containing a vertical stack of option sections.
private func makeGroupBox(title: String, views: [NSView]) -> NSBox {
let box = NSBox()
box.title = title
box.contentViewMargins = NSSize(width: 8, height: 8)

/// Builds a top-aligned tab page with full-width sections so help text wraps within the page.
private func makeOptionsTab(title: String, views: [NSView]) -> NSTabViewItem {
let item = NSTabViewItem(identifier: title)
item.label = title
let contentView = NSView()
item.view = contentView
let stack = NSStackView(views: views)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.setHuggingPriority(.required, for: .vertical)
stack.translatesAutoresizingMaskIntoConstraints = false

guard let contentView = box.contentView else { return box }
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16),
])
for view in views {
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}

return box
return item
}

/// Creates the smaller descriptive text used under each option row.
private func makeDescriptionLabel(_ description: String) -> NSTextField {
let label = NSTextField(labelWithString: description)
let label = NSTextField(wrappingLabelWithString: description)
label.font = .systemFont(ofSize: 11)
label.textColor = .secondaryLabelColor
label.alignment = .left
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}

Expand Down
Loading
Loading