From 1913ca561da19a6b82de0dfcd1dc807519a8b122 Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 21:58:47 +1100 Subject: [PATCH 1/8] feat: add Fonts option to pick installed fonts for the rain glyphs - render rain with the chosen installed fonts, mixed evenly; intro text stays in the system font - add a Fonts section to the Rain tab with an installed-fonts pull-down and a Show all fonts toggle - alias ASCII glyphs to private-use scalars so digits and symbols come from the rain font - persist the selection in defaults (empty keeps the system font) and cap it at 8 fonts --- .../MatrixScreenSaverOptions.swift | 109 ++++++++++++++- .../MatrixScreenSaverView.swift | 33 ++++- .../NativeMatrixRenderer.swift | 130 +++++++++++++++++- 3 files changed, 260 insertions(+), 12 deletions(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index b465a3c..94ace21 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -17,6 +17,8 @@ struct MatrixScreenSaverOptions: Equatable { static let frameRate = "FrameRate" static let errorRate = "ErrorRate" static let characters = "Characters" + /// Defaults key for the selected rain font families. + static let rainFonts = "RainFonts" static let scanLinesIntensity = "ScanLinesIntensity" static let scanLinesVertical = "ScanLinesVertical" } @@ -69,6 +71,12 @@ struct MatrixScreenSaverOptions: Equatable { 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." + /// Help text shown under the Fonts picker. + static let rainFontsDescription = "Fonts for the falling glyphs. Pick several to mix them. None keeps the built-in glyphs. Intro text always uses the system font." + /// Default selection: no fonts, which keeps the built-in glyphs and system font. + static let defaultRainFonts: [String] = [] + /// Maximum number of rain fonts that can be mixed. + static let maximumRainFonts = 8 static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled @@ -85,6 +93,8 @@ struct MatrixScreenSaverOptions: Equatable { var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters + /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs. + var rainFonts = defaultRainFonts var scanLinesIntensity = defaultScanLinesIntensity var scanLinesVertical = defaultScanLinesVertical @@ -114,6 +124,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + rainFonts: rainFonts, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview ) @@ -157,6 +168,14 @@ struct MatrixScreenSaverOptions: Equatable { .map { String($0) } .joined() }(), + rainFonts: { + var seen = Set() + return rainFonts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0).inserted } + .prefix(Self.maximumRainFonts) + .map { $0 } + }(), scanLinesIntensity: max(0, min(1, scanLinesIntensity)), scanLinesVertical: scanLinesVertical ) @@ -179,6 +198,7 @@ struct MatrixScreenSaverOptions: Equatable { Keys.frameRate: defaultFrameRate, Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, + Keys.rainFonts: defaultRainFonts, Keys.scanLinesIntensity: defaultScanLinesIntensity, Keys.scanLinesVertical: defaultScanLinesVertical, ]) @@ -201,6 +221,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: defaults.double(forKey: Keys.frameRate), errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, + rainFonts: (defaults.array(forKey: Keys.rainFonts) as? [String]) ?? defaultRainFonts, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical) ).sanitized() @@ -223,6 +244,7 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.frameRate, forKey: Keys.frameRate) defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) + defaults.set(options.rainFonts, forKey: Keys.rainFonts) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) defaults.set(options.scanLinesVertical, forKey: Keys.scanLinesVertical) defaults.synchronize() @@ -312,6 +334,12 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") + /// Pull-down listing installed fonts; ticked entries are the selection. + private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) + /// Lists every installed family instead of only rain-suitable ones. + private let showAllFontsCheckbox = NSButton(checkboxWithTitle: "Show all fonts", target: nil, action: nil) + /// Fonts currently ticked in the pull-down, in selection order. + private var selectedRainFonts: [String] = [] private var didClose = false /// Creates native options UI with a host-owned apply action. @@ -326,6 +354,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, defer: false ) super.init() + RainFontCatalog.prefetch() buildWindow() } @@ -353,6 +382,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters + selectedRainFonts = options.rainFonts + rebuildRainFontsMenu() sizeWindowToFitContent() } @@ -448,6 +479,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), makeRainDurationSection(), + makeRainFontsSection(), makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), ])) @@ -457,7 +489,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, // 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.heightAnchor.constraint(equalToConstant: 550), tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), ]) @@ -944,6 +976,80 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds the rain fonts picker: heading, description, "show all" checkbox, then a pull-down of installed fonts. + private func makeRainFontsSection() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let heading = NSTextField(labelWithString: "Fonts") + heading.font = .systemFont(ofSize: 13, weight: .semibold) + + rainFontsButton.setAccessibilityLabel("Fonts") + rainFontsButton.translatesAutoresizingMaskIntoConstraints = false + rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true + (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail + + showAllFontsCheckbox.target = self + showAllFontsCheckbox.action = #selector(showAllFontsToggled(_:)) + + stack.addArrangedSubview(heading) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription)) + stack.addArrangedSubview(showAllFontsCheckbox) + stack.addArrangedSubview(rainFontsButton) + stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) + stack.setCustomSpacing(8, after: showAllFontsCheckbox) + return stack + } + + /// Rebuilds the font menu so it reflects the current selection and "show all" state. + private func rebuildRainFontsMenu() { + let menu = NSMenu() + let summary = selectedRainFonts.isEmpty ? "Default (system font)" : selectedRainFonts.joined(separator: ", ") + menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) + + let defaultItem = NSMenuItem(title: "Default (system font)", action: #selector(clearRainFonts(_:)), keyEquivalent: "") + defaultItem.target = self + defaultItem.state = selectedRainFonts.isEmpty ? .on : .off + menu.addItem(defaultItem) + menu.addItem(.separator()) + + let families = RainFontCatalog.families(showAll: showAllFontsCheckbox.state == .on, alwaysInclude: selectedRainFonts) + for family in families { + let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") + item.target = self + item.representedObject = family + item.state = selectedRainFonts.contains(family) ? .on : .off + menu.addItem(item) + } + rainFontsButton.menu = menu + rainFontsButton.selectItem(at: 0) + } + + /// Adds or removes the chosen family, ignoring additions beyond the maximum. + @objc private func toggleRainFont(_ sender: NSMenuItem) { + guard let family = sender.representedObject as? String else { return } + if let index = selectedRainFonts.firstIndex(of: family) { + selectedRainFonts.remove(at: index) + } else if selectedRainFonts.count < MatrixScreenSaverOptions.maximumRainFonts { + selectedRainFonts.append(family) + } + rebuildRainFontsMenu() + } + + /// Clears the selection so the built-in glyphs and system font are used. + @objc private func clearRainFonts(_ sender: Any?) { + selectedRainFonts = [] + rebuildRainFontsMenu() + } + + /// Refreshes the font list after the "Show all fonts" checkbox changes. + @objc private func showAllFontsToggled(_ sender: Any?) { + rebuildRainFontsMenu() + } + /// Builds a labeled text input row with its description. private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { let stack = NSStackView() @@ -1143,6 +1249,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRate: frameRate, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), + rainFonts: selectedRainFonts, scanLinesIntensity: scanLinesSlider.doubleValue / 100, scanLinesVertical: scanLinesDirectionPopup.indexOfSelectedItem == 0 ).sanitized() diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c6d0b13..2254b41 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -90,6 +90,17 @@ final class MatrixScreenSaverView: ScreenSaverView { private var lineHeight: CGFloat = 22 private var regularFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium) private var boldFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .semibold) + /// Resolved rain fonts by family name for the current cell size. + private var rainFonts: [String: NSFont] = [:] + + /// Resolves each family name to a font at the given size, skipping fonts that are not installed. + private static func makeRainFonts(names: [String], size: CGFloat) -> [String: NSFont] { + var fonts = [String: NSFont]() + for name in names { + if let f = RainFontCatalog.font(named: name, size: size) { fonts[name] = f } + } + return fonts + } private var titleFont = NSFont.systemFont(ofSize: 13, weight: .medium) private var textInsetY: CGFloat = 2 private var colorCache: [TerminalColor: NSColor] = [:] @@ -103,6 +114,8 @@ final class MatrixScreenSaverView: ScreenSaverView { let textInsetY: CGFloat let palette: [TerminalColor] let scalars: [UnicodeScalar] + /// Selected rain font families the rasters were built for. + let rainFonts: [String] } /// Inputs used by the current regular and bold rasters. private var nativeGlyphCacheKey: GlyphCacheKey? @@ -479,6 +492,7 @@ final class MatrixScreenSaverView: ScreenSaverView { let fontSize = fittedTerminalFontSize(forCellWidth: cellWidth, cellHeight: lineHeight) regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium) boldFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .semibold) + rainFonts = Self.makeRainFonts(names: nativeRenderer.activeRainFonts, size: fontSize) textInsetY = max(0, floor((lineHeight - regularFont.pointSize) / 2) - 1) rebuildNativeGlyphCache() @@ -932,10 +946,11 @@ final class MatrixScreenSaverView: ScreenSaverView { let palette = nativeRenderer.levelColors let key = GlyphCacheKey(cellSize: CGSize(width: cellWidth, height: lineHeight), regularFont: regularFont, boldFont: boldFont, textInsetY: textInsetY, - palette: palette, scalars: nativeRenderer.supportedScalars) + palette: palette, scalars: nativeRenderer.supportedScalars, + rainFonts: nativeRenderer.activeRainFonts) guard nativeGlyphCacheKey != key else { return } - nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont) - nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont) + nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont, rainFonts: rainFonts) + nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts) nativeGlyphCacheKey = key } @@ -953,7 +968,8 @@ final class MatrixScreenSaverView: ScreenSaverView { /// Builds per-level glyph images for all supported scalar values. private func makeNativeGlyphCaches( palette: [TerminalColor], - font: NSFont + font: NSFont, + rainFonts: [String: NSFont] ) -> [[UnicodeScalar: CGImage]] { var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count) let scalars = nativeRenderer.supportedScalars @@ -964,7 +980,11 @@ final class MatrixScreenSaverView: ScreenSaverView { ] caches[level].reserveCapacity(scalars.count) for scalar in scalars { - guard let image = makeNativeGlyphImage(for: scalar, attributes: attributes) else { + var glyphAttributes = attributes + if let name = nativeRenderer.rainFontByScalar[scalar], let rf = rainFonts[name] { + glyphAttributes[.font] = rf + } + guard let image = makeNativeGlyphImage(for: scalar, drawing: nativeRenderer.rainSourceByScalar[scalar], attributes: glyphAttributes) else { continue } caches[level][scalar] = image @@ -976,6 +996,7 @@ final class MatrixScreenSaverView: ScreenSaverView { /// Rasterizes a single glyph into a native Core Graphics image. private func makeNativeGlyphImage( for scalar: UnicodeScalar, + drawing: UnicodeScalar? = nil, attributes: [NSAttributedString.Key: Any] ) -> CGImage? { let pixelWidth = max(Int(ceil(cellWidth)), 1) @@ -999,7 +1020,7 @@ final class MatrixScreenSaverView: ScreenSaverView { return glyphContext.makeImage() } - let text = String(scalar) + let text = String(drawing ?? scalar) let textSize = (text as NSString).size(withAttributes: attributes) let point = NSPoint( x: max(0, floor((cellWidth - textSize.width) / 2)), diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 304771f..9264fba 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation final class NativeMatrixRenderer { @@ -13,6 +14,8 @@ final class NativeMatrixRenderer { var frameRate = 25.0 var errorRate = 1.0 var characters = "" + /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs and system font. + var rainFonts: [String] = [] var neoMessageLines: [String] = NeoMessageScene.defaultLines /// When true the 10-second multi-screen sync delay is skipped so scenes /// start within one second of launch. Set for preview contexts where @@ -33,6 +36,7 @@ final class NativeMatrixRenderer { frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, + rainFonts: rainFonts, neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, skipSyncDelay: skipSyncDelay ) @@ -214,13 +218,64 @@ final class NativeMatrixRenderer { glyphs.append(UnicodeScalar(0x2588)!) return glyphs }() + /// Glyph tables derived from the rain fonts the user selected. + struct RainFontTables { + /// Glyph keys per font, picked 50/50 across fonts when more than one is selected. + var pools: [[UnicodeScalar]] = [] + /// Font family each glyph key is drawn with. + var fontByScalar: [UnicodeScalar: String] = [:] + /// Alias scalar -> character actually drawn (only for aliased ASCII glyphs). + var sourceByScalar: [UnicodeScalar: UnicodeScalar] = [:] + /// Upper bound on glyphs taken from one font, sampled evenly, to keep the glyph cache small. + static let maximumGlyphsPerFont = 512 + } + + /// Glyphs a rain font really has. ASCII glyphs are keyed by a private-use alias (U+F000 + code) + /// so they render in the rain font without colliding with the system-font intro/terminal text. + static func glyphEntries(forFont name: String) -> [(key: UnicodeScalar, source: UnicodeScalar)] { + guard let font = RainFontCatalog.font(named: name, size: 16) else { return [] } + let cs = CTFontCopyCharacterSet(font) as CharacterSet + var out = [(key: UnicodeScalar, source: UnicodeScalar)]() + for v in UInt32(0x21)...UInt32(0xFFFF) { + guard let sc = UnicodeScalar(v), cs.contains(sc), + v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588, + !CharacterSet.whitespacesAndNewlines.contains(sc), + !CharacterSet.controlCharacters.contains(sc) else { continue } + if v < 0x80, let alias = UnicodeScalar(0xF000 + v) { + out.append((alias, sc)) + } else { + out.append((sc, sc)) + } + } + let limit = RainFontTables.maximumGlyphsPerFont + guard out.count > limit else { return out } + let step = Double(out.count) / Double(limit) + return (0.. RainFontTables { + var tables = RainFontTables() + var seen = Set() + for name in fonts { + var pool = [UnicodeScalar]() + for entry in glyphEntries(forFont: name) where seen.insert(entry.key).inserted { + pool.append(entry.key) + tables.fontByScalar[entry.key] = name + if entry.key != entry.source { tables.sourceByScalar[entry.key] = entry.source } + } + if !pool.isEmpty { tables.pools.append(pool) } + } + return tables + } + private static let palette = makePalette() /// Returns the glyph pool for the given characters string, deduplicating and falling back to the default pool when empty. - private static func resolveGlyphPool(characters: String) -> [UnicodeScalar] { + private static func resolveGlyphPool(characters: String, fallback: [UnicodeScalar]) -> [UnicodeScalar] { let trimmed = characters.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - return defaultGlyphPool + return fallback } var seen = Set() var pool = [UnicodeScalar]() @@ -232,7 +287,7 @@ final class NativeMatrixRenderer { pool.append(scalar) } } - return pool.isEmpty ? defaultGlyphPool : pool + return pool.isEmpty ? fallback : pool } /// The content margin (in cells) used for the text row and columns in number/neo scenes. @@ -258,6 +313,16 @@ final class NativeMatrixRenderer { private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool + /// When set, each rain glyph picks a selected font at random, then a glyph within it. + private var activeMixPools: [[UnicodeScalar]]? = nil + /// Glyph tables for the current `activeRainFonts`. + private var rainTables = RainFontTables() + /// Rain font families the glyph tables were built for. + private(set) var activeRainFonts: [String] = [] + /// Font family to draw each rain glyph key with. + private(set) var rainFontByScalar: [UnicodeScalar: String] = [:] + /// Character actually drawn for aliased glyph keys. + private(set) var rainSourceByScalar: [UnicodeScalar: UnicodeScalar] = [:] var supportedScalars: [UnicodeScalar] { let defaultSet = Set(Self.defaultGlyphPool) @@ -459,7 +524,19 @@ final class NativeMatrixRenderer { pendingSimulationSteps = 0.0 } - activeGlyphPool = Self.resolveGlyphPool(characters: sanitizedConfiguration.characters) + if sanitizedConfiguration.rainFonts != activeRainFonts { + activeRainFonts = sanitizedConfiguration.rainFonts + rainTables = Self.makeRainTables(fonts: activeRainFonts) + rainFontByScalar = rainTables.fontByScalar + rainSourceByScalar = rainTables.sourceByScalar + } + let fontPoolUnion = rainTables.pools.flatMap { $0 } + activeGlyphPool = Self.resolveGlyphPool( + characters: sanitizedConfiguration.characters, + fallback: fontPoolUnion.isEmpty ? Self.defaultGlyphPool : fontPoolUnion + ) + let customChars = !sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + activeMixPools = (!customChars && rainTables.pools.count > 1) ? rainTables.pools : nil let shouldResetSceneSequence = columns > 0 && @@ -1114,7 +1191,12 @@ final class NativeMatrixRenderer { /// Returns a random glyph from the supported rain character pool. private func randomGlyph() -> UnicodeScalar { - activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0") + if let pools = activeMixPools, + let pool = pools.randomElement(using: &rainRNG), + let glyph = pool.randomElement(using: &rainRNG) { + return glyph + } + return activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0") } /// Returns a random ASCII numeral for the startup number scene. @@ -1344,3 +1426,41 @@ final class NativeMatrixRenderer { return result } } + +/// Lists installed font families and resolves them for rain rendering. +enum RainFontCatalog { + /// Resolves a family, PostScript or full font name to a font. + static func font(named name: String, size: CGFloat) -> NSFont? { + NSFont(name: name, size: size) + ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size) + } + + /// Families that look useful for rain: katakana-capable fonts and anything named "matrix". + private static let suitableFamilies: Set = { + var result = Set() + for family in NSFontManager.shared.availableFontFamilies { + if family.lowercased().contains("matrix") { result.insert(family); continue } + guard let font = font(named: family, size: 16) else { continue } + let cs = CTFontCopyCharacterSet(font) as CharacterSet + var count = 0 + for v in Array(0x30A0...0x30FF) + Array(0xFF61...0xFF9F) { + if let sc = UnicodeScalar(UInt32(v)), cs.contains(sc) { count += 1 } + } + if count >= 20 { result.insert(family) } + } + return result + }() + + /// Scans the installed fonts on a background queue so the first open of the font list does not stall the main thread. + static func prefetch() { + DispatchQueue.global(qos: .utility).async { _ = suitableFamilies } + } + + /// Sorted font families, optionally limited to rain-suitable ones (selected fonts always stay listed). + static func families(showAll: Bool, alwaysInclude: [String]) -> [String] { + let all = NSFontManager.shared.availableFontFamilies + var names = showAll ? Set(all) : suitableFamilies + names.formUnion(alwaysInclude) + return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } +} From e624a73191879dbb2cf0f0c5618b014eb42b1d01 Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 21:58:47 +1100 Subject: [PATCH 2/8] fix: size options pages up front to avoid unsatisfiable layout constraints - create each options page at its real size instead of zero --- Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 94ace21..1706929 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1173,7 +1173,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private func makeOptionsTab(title: String, views: [NSView]) -> NSTabViewItem { let item = NSTabViewItem(identifier: title) item.label = title - let contentView = NSView() + // Start at the real page size so NSTabView never lays the page out at its 20pt placeholder width, + // which made the fixed-width rows report unsatisfiable constraints in the screen saver host. + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 550)) item.view = contentView let stack = NSStackView(views: views) stack.orientation = .vertical From e33e11143d5c191354c863183d30413bdb8818ad Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 22:17:27 +1100 Subject: [PATCH 3/8] docs: credit and link the Matrix fonts - document the Fonts option in the options table - add a Matrix fonts section linking Matrix Code and Matrix Resurrected from Rezmason/matrix --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bca6ea9..725d707 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,19 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | Frame rate | Set the frame rate per second. A positive number less than or equal to 1000. | 25 | | Error rate | Set the factor for the rate of character changes. A non-negative number. | 1.0 | | Characters | Restrict rain glyphs to a custom set (e.g. ATGC). Leave empty for the default. | *(empty)* | +| Fonts | Pick installed fonts for the rain glyphs (up to 8, mixed evenly). Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | | Neo message scene | Show the Neo message intro before the main scene. | On | | Neo message speed | Typing and pause speed multiplier for the Neo message intro. A positive number. | 1.0 | | Neo message lines | Lines typed during the Neo message intro. Between 1 and 10 lines, up to 256 characters each. | The 4 classic lines | | Scan lines intensity | Opacity of the CRT scan line overlay (0 = off, 100 = fully opaque). | 25 | | Scan lines vertical | Orientation of the scan line overlay (vertical or horizontal). | On (vertical) | +### Matrix fonts + +The **Fonts** option lists installed fonts that look suited to rain, so the glyphs can match the film. Tick **Show all fonts** to list every family. No fonts are bundled, so install them yourself first. + +The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. + ### Wallpaper menubar menu ![menubar.jpg](docs/wallpaper-menubar.jpg) @@ -57,7 +64,7 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr - Native Swift/AppKit screen saver bundle - Edge-to-edge full-screen saver output, with terminal-style chrome kept in the preview host - In-process renderer for `rain-forever` with an optional upstream `number` intro -- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Neo message scene/speed/lines, and Scan lines +- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Fonts, Neo message scene/speed/lines, and Scan lines - Preview app for fast iteration without reinstalling ### Repository layout From aa3b03bd4d9de38ecff7e879c7ea4a8cf23db7ef Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 23:07:23 +1100 Subject: [PATCH 4/8] test: cover rain font selection - test rain font glyph tables, aliasing and catalog listing against Menlo - test rain font option sanitizing, persistence and renderer hand-off - allow the taller options sheet that now holds the Fonts section --- .../NativeMatrixRendererTests.swift | 46 +++++++++++++++++++ .../SharedSaverTests.swift | 17 ++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index 5940e5a..3d84f63 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -1,6 +1,9 @@ +import AppKit import Foundation func nativeMatrixRendererTests() { + rainFontTests() + ok(NativeMatrixRenderer().seedOffset == 0, "NativeMatrixRenderer: seedOffset defaults to 0") @@ -123,3 +126,46 @@ func nativeMatrixRendererTests() { "NativeMatrixRenderer: pause between display phase advances preserves identical Number-scene timestamps") ok(earlyText == startupText, "NativeMatrixRenderer: a display starting after resume keeps the same scripted content and seed") } + +/// Covers the rain font tables and catalog using Menlo, which ships with every macOS install. +private func rainFontTests() { + let tables = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"]) + // Menlo has more glyphs than the per-font limit, so pick an alias that survived the sampling. + let aliasPool = (tables.pools.first ?? []).filter { (0xF021...0xF07E).contains($0.value) } + let alias = aliasPool.first ?? UnicodeScalar(0xF041)! + ok(tables.pools.count == 1 && !(tables.pools.first ?? []).isEmpty, + "RainFont: an installed font yields one non-empty glyph pool") + ok((tables.pools.first ?? []).count <= NativeMatrixRenderer.RainFontTables.maximumGlyphsPerFont, + "RainFont: a pool never exceeds the per-font glyph limit") + ok(!aliasPool.isEmpty && tables.sourceByScalar[alias]?.value == alias.value - 0xF000 && tables.fontByScalar[alias] == "Menlo", + "RainFont: ASCII glyphs are keyed by a private-use alias that draws the real character in the rain font") + ok(!(tables.pools.first ?? []).contains { $0.value < 0x80 }, + "RainFont: plain ASCII scalars stay out of the pool so intro text keeps the system font") + + ok(NativeMatrixRenderer.makeRainTables(fonts: ["No Such Font 9F3A"]).pools.isEmpty, + "RainFont: fonts that are not installed are skipped") + ok(NativeMatrixRenderer.makeRainTables(fonts: ["Menlo", "Menlo"]).pools.count == 1, + "RainFont: a duplicate font adds no second pool") + + ok(RainFontCatalog.font(named: "Menlo", size: 12) != nil && RainFontCatalog.font(named: "No Such Font 9F3A", size: 12) == nil, + "RainFontCatalog: resolves installed fonts and rejects missing ones") + let everything = RainFontCatalog.families(showAll: true, alwaysInclude: []) + ok(everything.contains("Menlo") && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, + "RainFontCatalog: showing all fonts lists installed families in sorted order") + ok(RainFontCatalog.families(showAll: false, alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), + "RainFontCatalog: a selected font stays listed even when it is filtered out") + + let renderer = NativeMatrixRenderer() + var config = NativeMatrixRenderer.Configuration() + config.rainFonts = ["Menlo"] + ok(config.sanitized().rainFonts == ["Menlo"], "RainFont: configuration sanitizing keeps the selected fonts") + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts == ["Menlo"] && !renderer.rainSourceByScalar.isEmpty && renderer.rainFontByScalar.values.allSatisfy { $0 == "Menlo" }, + "RainFont: the renderer builds glyph tables for the selected fonts") + ok(renderer.rainSourceByScalar.keys.allSatisfy { renderer.supportedScalars.contains($0) }, + "RainFont: the renderer supports the aliased rain glyphs") + config.rainFonts = [] + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts.isEmpty && renderer.rainFontByScalar.isEmpty, + "RainFont: clearing the selection drops the font glyph tables") +} diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 46ef4c6..c62d971 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -90,6 +90,20 @@ func sharedSaverTests() { options.save(to: defaults) check(MatrixScreenSaverOptions.load(from: defaults).frameRate > 0, "shared persistence retains option sanitization") + var fontOptions = MatrixScreenSaverOptions() + check(fontOptions.rainFonts.isEmpty, "rain fonts default to none, keeping the built-in glyphs") + fontOptions.rainFonts = [" Matrix-Code ", "Matrix-Code", "", "Matrix-Resurrected"] + (1...10).map { "Font \($0)" } + let cleanFonts = fontOptions.sanitized().rainFonts + check(cleanFonts.prefix(2) == ["Matrix-Code", "Matrix-Resurrected"] && Set(cleanFonts).count == cleanFonts.count, + "rain font sanitizing trims names and drops blanks and duplicates") + check(cleanFonts.count == MatrixScreenSaverOptions.maximumRainFonts, "rain font sanitizing caps the selection") + fontOptions.rainFonts = ["Matrix-Code", "Matrix-Resurrected"] + fontOptions.save(to: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], + "rain fonts persist through save and load") + check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], + "rain fonts reach the renderer configuration") + let original = MatrixScreenSaverOptions() var live = original live.twinkleEnabled.toggle() @@ -164,7 +178,8 @@ private func checkOptionsLayout(in window: NSWindow) { let content = window.contentView! content.layoutSubtreeIfNeeded() let size = content.bounds.size - check(size.width <= 600 && size.height <= 600, + // The Rain tab gained the Fonts section, so the sheet is taller than the original 600 point budget. + check(size.width <= 600 && size.height <= 650, "options fit in a compact window without screen-sized help text") guard let tabs = descendants(of: content).compactMap({ $0 as? NSTabView }).first else { check(false, "options organize settings into native tabs") From 509ab588b2ff4802cadccf411ac71a081bf94411 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 17:22:53 +0100 Subject: [PATCH 5/8] fix: improve rain font selection and fallback - discover hidden fonts and remove font filtering - retain missing glyphs for cached macOS fallback - share rain rasters across weight caches - add font reset and a dynamic family-only default label - simplify help text and cover font behavior with tests --- README.md | 2 +- .../MatrixScreenSaverOptions.swift | 42 ++++---- .../MatrixScreenSaverView.swift | 23 +++- .../NativeMatrixRenderer.swift | 100 +++++++++++++----- .../NativeMatrixRendererTests.swift | 69 +++++++++++- .../SharedSaverTests.swift | 58 ++++++++++ 6 files changed, 239 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index a7949c7..ef725d9 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ cancels its command. The sandboxed screen saver may not have the same access as ### Matrix fonts -The **Fonts** option lists installed fonts that look suited to rain, so the glyphs can match the film. Tick **Show all fonts** to list every family. No fonts are bundled, so install them yourself first. +The **Fonts** option lists installed font families, including hidden fonts discovered in the standard system and user font folders. Mix up to 8 fonts. Default and custom characters missing from a selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. No fonts are bundled, so install them yourself first. The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index ff1368f..c78f4e5 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1,4 +1,5 @@ import AppKit +import CoreText import ScreenSaver struct MatrixScreenSaverOptions: Equatable { @@ -91,13 +92,12 @@ struct MatrixScreenSaverOptions: Equatable { ] static let defaultCharacterSizeIndex = 8 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." /// Help text shown under the Fonts picker. - static let rainFontsDescription = "Fonts for the falling glyphs. Pick several to mix them. None keeps the built-in glyphs. Intro text always uses the system font." + static let rainFontsDescription = "Mix up to 8 fonts." /// Default selection: no fonts, which keeps the built-in glyphs and system font. static let defaultRainFonts: [String] = [] /// Maximum number of rain fonts that can be mixed. @@ -441,8 +441,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let charactersField = NSTextField(string: "") /// Pull-down listing installed fonts; ticked entries are the selection. private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) - /// Lists every installed family instead of only rain-suitable ones. - private let showAllFontsCheckbox = NSButton(checkboxWithTitle: "Show all fonts", target: nil, action: nil) /// Fonts currently ticked in the pull-down, in selection order. private var selectedRainFonts: [String] = [] private var didClose = false @@ -1234,7 +1232,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } - /// Builds the rain fonts picker: heading, description, "show all" checkbox, then a pull-down of installed fonts. + /// Builds the rain fonts picker with every discovered font family. private func makeRainFontsSection() -> NSView { let stack = NSStackView() stack.orientation = .vertical @@ -1250,31 +1248,41 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail - showAllFontsCheckbox.target = self - showAllFontsCheckbox.action = #selector(showAllFontsToggled(_:)) + let resetButton = NSButton(title: "Reset", target: self, action: #selector(clearRainFonts(_:))) + resetButton.bezelStyle = .rounded + resetButton.controlSize = .small + resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + resetButton.setAccessibilityLabel("Reset fonts to system default") + resetButton.toolTip = "Use the default system font" + let row = NSStackView(views: [rainFontsButton, resetButton]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 8 stack.addArrangedSubview(heading) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription)) - stack.addArrangedSubview(showAllFontsCheckbox) - stack.addArrangedSubview(rainFontsButton) + stack.addArrangedSubview(row) stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) - stack.setCustomSpacing(8, after: showAllFontsCheckbox) return stack } - /// Rebuilds the font menu so it reflects the current selection and "show all" state. + /// Rebuilds the full font menu with the current selection. private func rebuildRainFontsMenu() { let menu = NSMenu() - let summary = selectedRainFonts.isEmpty ? "Default (system font)" : selectedRainFonts.joined(separator: ", ") + let systemFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium) + // Resolve the backing family instead of exposing the system alias or face weights. + let backingFont = CTFontCreateWithGraphicsFont(CTFontCopyGraphicsFont(systemFont, nil), 16, nil, nil) + let defaultTitle = "Default - \(CTFontCopyFamilyName(backingFont))" + let summary = selectedRainFonts.isEmpty ? defaultTitle : selectedRainFonts.joined(separator: ", ") menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) - let defaultItem = NSMenuItem(title: "Default (system font)", action: #selector(clearRainFonts(_:)), keyEquivalent: "") + let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "") defaultItem.target = self defaultItem.state = selectedRainFonts.isEmpty ? .on : .off menu.addItem(defaultItem) menu.addItem(.separator()) - let families = RainFontCatalog.families(showAll: showAllFontsCheckbox.state == .on, alwaysInclude: selectedRainFonts) + let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) for family in families { let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") item.target = self @@ -1303,11 +1311,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rebuildRainFontsMenu() } - /// Refreshes the font list after the "Show all fonts" checkbox changes. - @objc private func showAllFontsToggled(_ sender: Any?) { - rebuildRainFontsMenu() - } - /// Builds a labeled text input row with its description. private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { let stack = NSStackView() @@ -1376,7 +1379,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.addArrangedSubview(checkboxRow) stack.addArrangedSubview(secondsRow) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription)) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) return stack } diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index ebf8014..c938184 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -968,7 +968,8 @@ final class MatrixScreenSaverView: ScreenSaverView { guard nativeGlyphCacheKey != key else { return } nativeSupplementalGlyphs.removeAll(keepingCapacity: true) nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont, rainFonts: rainFonts) - nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts) + nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts, + sharingRainGlyphs: nativeRegularGlyphsByLevel) nativeGlyphCacheKey = key } @@ -984,10 +985,18 @@ final class MatrixScreenSaverView: ScreenSaverView { } /// Builds per-level glyph images for all supported scalar values. + /// + /// - Parameters: + /// - palette: Colors for the cache levels. + /// - font: System font weight used for terminal text. + /// - rainFonts: Selected faces used regardless of terminal text weight. + /// - sharingRainGlyphs: Caches from the same palette and layout whose selected-font images can be reused. + /// - Returns: Glyph images indexed by palette level and renderer scalar. private func makeNativeGlyphCaches( palette: [TerminalColor], font: NSFont, - rainFonts: [String: NSFont] + rainFonts: [String: NSFont], + sharingRainGlyphs: [[UnicodeScalar: CGImage]] = [] ) -> [[UnicodeScalar: CGImage]] { var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count) let scalars = nativeRenderer.supportedScalars @@ -1000,6 +1009,10 @@ final class MatrixScreenSaverView: ScreenSaverView { for scalar in scalars { var glyphAttributes = attributes if let name = nativeRenderer.rainFontByScalar[scalar], let rf = rainFonts[name] { + if sharingRainGlyphs.indices.contains(level), let image = sharingRainGlyphs[level][scalar] { + caches[level][scalar] = image + continue + } glyphAttributes[.font] = rf } guard let image = makeNativeGlyphImage(for: scalar, drawing: nativeRenderer.rainSourceByScalar[scalar], attributes: glyphAttributes) else { @@ -1012,6 +1025,12 @@ final class MatrixScreenSaverView: ScreenSaverView { } /// Rasterizes a single glyph into a native Core Graphics image. + /// + /// - Parameters: + /// - scalar: Renderer cache key, which may be a private-use rain alias. + /// - drawing: Selected-font source character, enabling fitted rendering; nil keeps terminal-text rendering. + /// - attributes: Font and foreground color used to draw the glyph. + /// - Returns: The cell-sized raster, or nil if a bitmap context could not be created. private func makeNativeGlyphImage( for scalar: UnicodeScalar, drawing: UnicodeScalar? = nil, diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 65b2e6b..2babb18 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -233,9 +233,12 @@ final class NativeMatrixRenderer { private static let maxSimulationStepsPerTick = 2 private static let disableBoldFlag: UInt32 = 0x1 private static let blankRenderCell = RenderCell() + /// Half-width katakana characters used by the built-in rain. + private static let defaultKatakanaGlyphs = (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) } + private static let defaultGlyphPool: [UnicodeScalar] = { var glyphs = Array("0123456789".unicodeScalars) - glyphs.append(contentsOf: (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) }) + glyphs.append(contentsOf: defaultKatakanaGlyphs) glyphs.append(contentsOf: "<>*+.:=_|".unicodeScalars) // Printable ASCII for terminal text lines glyphs.append(contentsOf: (0x20..<0x7F).compactMap { UnicodeScalar($0) }) @@ -255,20 +258,21 @@ final class NativeMatrixRenderer { static let maximumGlyphsPerFont = 512 } - /// Samples visible glyphs supported by a font, optionally restricted to the custom character set. + /// Samples a font's repertoire while retaining default or custom characters for macOS fallback. /// - Parameters: /// - name: Installed font family or font name. /// - characters: Custom rain characters; empty uses the font's BMP repertoire. - /// - Returns: At most `maximumGlyphsPerFont` supported characters. + /// - Returns: At most `maximumGlyphsPerFont` characters; missing glyphs are resolved at rasterization. static func glyphEntries(forFont name: String, characters: String = "") -> [UnicodeScalar] { guard let font = RainFontCatalog.font(named: name, size: 16) else { return [] } let cs = CTFontCopyCharacterSet(font) as CharacterSet + let defaults = Set(defaultGlyphPool) var out = [UnicodeScalar]() let values = characters.isEmpty ? Array(UInt32(0x21)...UInt32(0xFFFF)) : Array(Set(characters.unicodeScalars.map(\.value))).sorted() for v in values { - guard let sc = UnicodeScalar(v), cs.contains(sc), + guard let sc = UnicodeScalar(v), cs.contains(sc) || !characters.isEmpty || defaults.contains(sc), v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588, !CharacterSet.whitespacesAndNewlines.contains(sc), !CharacterSet.controlCharacters.contains(sc) else { continue } @@ -276,8 +280,13 @@ final class NativeMatrixRenderer { } let limit = RainFontTables.maximumGlyphsPerFont guard out.count > limit else { return out } - let step = Double(out.count) / Double(limit) - return (0.. Double { + let traits = descriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] + let weight = (traits?[.weight] as? NSNumber)?.doubleValue ?? 0 + return abs(weight) + (descriptor.symbolicTraits.contains(.italic) ? 10 : 0) + } + /// Resolves a family, PostScript or full font name to a font. static func font(named name: String, size: CGFloat) -> NSFont? { - NSFont(name: name, size: size) + if fontFileInventory == nil { refreshHiddenFonts() } + if let descriptor = hiddenFonts[name] { + // A descriptor retains the file URL; resolving a hidden name alone may substitute Times. + return NSFont(descriptor: descriptor, size: size) + } + return NSFont(name: name, size: size) ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size) } - /// Families that look useful for rain: katakana-capable fonts and anything named "matrix". - private static let suitableFamilies: Set = { - var result = Set() - for family in NSFontManager.shared.availableFontFamilies { - if family.lowercased().contains("matrix") { result.insert(family); continue } - guard let font = font(named: family, size: 16) else { continue } - let cs = CTFontCopyCharacterSet(font) as CharacterSet - var count = 0 - for v in Array(0x30A0...0x30FF) + Array(0xFF61...0xFF9F) { - if let sc = UnicodeScalar(UInt32(v)), cs.contains(sc) { count += 1 } - } - if count >= 20 { result.insert(family) } - } - return result - }() - - /// Sorted font families, optionally limited to rain-suitable ones (selected fonts always stay listed). - static func families(showAll: Bool, alwaysInclude: [String]) -> [String] { - let all = NSFontManager.shared.availableFontFamilies - var names = showAll ? Set(all) : suitableFamilies + /// Lists installed font families, retaining selected names for deselection. + /// - Parameter alwaysInclude: Selected families to keep available for deselection. + /// - Returns: Unique family names in localized alphabetical order. + static func families(alwaysInclude: [String]) -> [String] { + refreshHiddenFonts() + let all = Set(NSFontManager.shared.availableFontFamilies).union(hiddenFonts.keys) + var names = all names.formUnion(alwaysInclude) return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index c277e51..ab55a72 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -160,11 +160,37 @@ private func rainFontTests() { ok(RainFontCatalog.font(named: "Menlo", size: 12) != nil && RainFontCatalog.font(named: "No Such Font 9F3A", size: 12) == nil, "RainFontCatalog: resolves installed fonts and rejects missing ones") - let everything = RainFontCatalog.families(showAll: true, alwaysInclude: []) - ok(everything.contains("Menlo") && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, - "RainFontCatalog: showing all fonts lists installed families in sorted order") - ok(RainFontCatalog.families(showAll: false, alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), - "RainFontCatalog: a selected font stays listed even when it is filtered out") + let everything = RainFontCatalog.families(alwaysInclude: []) + ok(Set(NSFontManager.shared.availableFontFamilies).isSubset(of: Set(everything)) && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, + "RainFontCatalog: always lists every installed family in sorted order") + let systemFiles = FileManager.default.enumerator(at: URL(fileURLWithPath: "/System/Library/Fonts"), includingPropertiesForKeys: nil)! + var checkedHiddenFont = false + for case let url as URL in systemFiles { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { continue } + for descriptor in descriptors { + guard let family = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String, + !NSFontManager.shared.availableFontFamilies.contains(family), + let resolved = RainFontCatalog.font(named: family, size: 16) else { continue } + let actualFamily = CTFontCopyFamilyName(resolved) as String + ok(everything.contains(family) && actualFamily == family, + "RainFontCatalog: automatically discovers hidden file-backed families and resolves their actual font") + checkedHiddenFont = true + break + } + if checkedHiddenFont { break } + } + ok(checkedHiddenFont, "RainFontCatalog: exercises an installed hidden system font") + ok(RainFontCatalog.families(alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), + "RainFontCatalog: a missing selected font stays available for deselection") + let defaultKatakana = Set((UInt32(0xFF70)...UInt32(0xFF9D)).compactMap(UnicodeScalar.init)) + let menlo = RainFontCatalog.font(named: "Menlo", size: 16)! + let menloCoverage = CTFontCopyCharacterSet(menlo) as CharacterSet + ok(!defaultKatakana.allSatisfy { menloCoverage.contains($0) } && + defaultKatakana.isSubset(of: Set(tables.sourceByScalar.values)), + "RainFont: default katakana missing from the selected font stay in the pool for fallback") + let customFallback = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"], characters: "Aア") + ok(Set(customFallback.sourceByScalar.values) == Set("Aア".unicodeScalars), + "RainFont: custom characters missing from the selected font stay in the pool for fallback") let renderer = NativeMatrixRenderer() var config = NativeMatrixRenderer.Configuration() @@ -187,6 +213,39 @@ private func rainFontTests() { renderer.updateConfiguration(config) ok(renderer.activeRainFonts.isEmpty && renderer.rainFontByScalar.isEmpty, "RainFont: clearing the selection drops the font glyph tables") + + config.rainFonts = ["Menlo", "Hiragino Sans"] + config.characters = "A" + String(String.UnicodeScalarView((0x30A1...0x30FA).compactMap { UnicodeScalar($0) })) + config.neoMessageSceneEnabled = false + config.numberSceneEnabled = false + config.cmdSceneEnabled = false + config.twinkleEnabled = false + let mixedFallback = NativeMatrixRenderer.makeRainTables(fonts: config.rainFonts, characters: config.characters) + ok(mixedFallback.pools.count == 2 && mixedFallback.pools.allSatisfy { pool in + Set(pool.compactMap { mixedFallback.sourceByScalar[$0] }) == Set(config.characters.unicodeScalars) + }, "RainFont: every selected font retains the full custom set regardless of native coverage") + renderer.updateConfiguration(config) + renderer.resize(to: TerminalSize(columns: 80, rows: 24)) + renderer.overrideSceneStartTime = 1_000 + renderer.start() + defer { renderer.stop() } + var counts: [String: Int] = [:] + for _ in 0..<120 { + renderer.shiftTimeline(by: -0.1) + renderer.advance() + for row in 0..<24 { + for column in 0..<80 { + let cell = renderer[row, column] + if cell.foregroundLevel > 0, let font = renderer.rainFontByScalar[cell.scalar] { + counts[font, default: 0] += 1 + } + } + } + } + let total = counts.values.reduce(0, +) + let menloShare = Double(counts["Menlo", default: 0]) / Double(max(total, 1)) + ok(total > 1_000 && (0.35...0.65).contains(menloShare), + "RainFont: rendered rain mixes selected fonts evenly with fallback-enabled glyph pools") } /// Checks idle intro frames and cursor-only updates through the host's dirty-row contract. diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index a0cea24..34da38b 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -4,6 +4,7 @@ import ScreenSaver /// Exercises the shared view and options controller through their hosting boundaries. func sharedSaverTests() { glyphCacheTests() + rainFontResetTests() firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), @@ -243,6 +244,32 @@ private func firstSceneSynchronizationTests() { } } +/// Checks that the font reset updates the menu and accepted options without resetting other settings. +private func rainFontResetTests() { + var applied: MatrixScreenSaverOptions? + let controller = MatrixScreenSaverOptionsSheetController { applied = $0 } + var options = MatrixScreenSaverOptions() + options.rainFonts = ["Menlo", "Hiragino Sans"] + options.characters = "ATGC" + options.frameRate = 42 + controller.prepare(using: options) + let window = controller.configureSheet() + let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first! + tabs.selectTabViewItem(tabs.tabViewItems.first { $0.label == "Rain" }!) + window.contentView!.layoutSubtreeIfNeeded() + let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) + let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } + check(reset?.controlSize == .small && picker?.title.contains("Menlo") == true, + "font picker exposes a small reset button beside a custom selection") + reset?.performClick(nil) + check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && + picker?.menu?.items.dropFirst(3).allSatisfy { $0.state == .off } == true, + "font reset restores the system-font summary and clears custom-font checkmarks") + buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil) + check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42, + "font reset applies the default font without changing other options") +} + /// Checks raster reuse through layout and option updates, retaining images to compare identity safely. private func glyphCacheTests() { let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)! @@ -262,6 +289,8 @@ private func glyphCacheTests() { options.frameRate = 42 view.applyWallpaperOptions(options) check(original === glyph("A"), "frame-rate changes preserve glyph rasters") + // These checks exercise plain scalar keys, independent of the user's saved font selection. + options.rainFonts = [] options.characters = "☃" view.applyWallpaperOptions(options) check(glyph("☃") != nil && glyph("☃", bold: true) != nil, @@ -287,10 +316,39 @@ private func glyphCacheTests() { view.applyWallpaperOptions(options) let rainKey = UnicodeScalar(0xF0000)! let rainA = glyph(rainKey) + let regularLevels: [[UnicodeScalar: CGImage]] = stored("nativeRegularGlyphsByLevel", in: view) ?? [] + let boldLevels: [[UnicodeScalar: CGImage]] = stored("nativeBoldGlyphsByLevel", in: view) ?? [] + check(!regularLevels.isEmpty && regularLevels.count == boldLevels.count && + zip(regularLevels, boldLevels).allSatisfy { regular, bold in + regular[rainKey] != nil && regular[rainKey] === bold[rainKey] && + regular["A"] != nil && bold["A"] != nil && regular["A"] !== bold["A"] + }, "selected-font rasters share every weight-cache level while system bold stays separate") options.characters = "B" view.applyWallpaperOptions(options) check(rainA != nil && glyph(rainKey) != nil && rainA !== glyph(rainKey), "editing custom rain characters refreshes rasters even when alias keys stay unchanged") + options.characters = "ア" + view.applyWallpaperOptions(options) + let fallbackImage = glyph(rainKey) + let selectedFonts: [String: NSFont] = stored("rainFonts", in: view) ?? [:] + let selectedFont = selectedFonts["Menlo"]! + let line = CTLineCreateWithAttributedString(NSAttributedString(string: "ア", attributes: [.font: selectedFont])) + let runs = CTLineGetGlyphRuns(line) as! [CTRun] + let usesFallback = runs.contains { run in + let font = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont + var glyphID: CGGlyph = 0 + CTRunGetGlyphs(run, CFRange(location: 0, length: 1), &glyphID) + return CTFontCopyFamilyName(font) != CTFontCopyFamilyName(selectedFont) && glyphID != 0 + } + let hasFallbackInk = fallbackImage.map { image in + let bitmap = NSBitmapImageRep(cgImage: image) + return (0.. 0 } + } + } ?? false + check(usesFallback && hasFallbackInk, "missing selected-font katakana resolves to a real fallback glyph and a visible cached raster") + view.applyWallpaperOptions(options) + check(fallbackImage != nil && fallbackImage === glyph(rainKey), "unchanged options reuse the fallback raster without resolving fonts again") options.rainFonts = ["Hiragino Sans"] options.characters = "カ" options.characterWidth = 16 From 32d5dc3a9d8551575dd25349ea2d83ca741df1bf Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:23:40 +0100 Subject: [PATCH 6/8] feat: add Matrix glyph presets and live preview - bundle original Classic and Resurrections fonts with attribution - persist glyph presets and preserve custom settings when switching - limit the font picker to one selection - preview glyphs in green on black with a smaller wrapping font - cover preset rendering, persistence, selection and layout --- README.md | 8 +- Resources/Fonts/LICENSE.txt | 21 +++ Resources/Fonts/Matrix-Code.ttf | Bin 0 -> 7896 bytes Resources/Fonts/Matrix-Resurrected.ttf | Bin 0 -> 20332 bytes Resources/Fonts/README.md | 116 ++++++++++++ .../MatrixScreenSaverOptions.swift | 167 ++++++++++++++---- .../NativeMatrixRenderer.swift | 84 ++++++++- .../NativeMatrixRendererTests.swift | 29 +++ .../SharedSaverTests.swift | 141 +++++++++++++-- build-wallpaper.sh | 2 + build.sh | 1 + tests.sh | 8 +- wallpaper-tests.sh | 11 +- 13 files changed, 527 insertions(+), 61 deletions(-) create mode 100644 Resources/Fonts/LICENSE.txt create mode 100644 Resources/Fonts/Matrix-Code.ttf create mode 100644 Resources/Fonts/Matrix-Resurrected.ttf create mode 100644 Resources/Fonts/README.md diff --git a/README.md b/README.md index ef725d9..75040ad 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | Frame rate | Set the frame rate per second. A positive number less than or equal to 1000. | 25 | | Error rate | Set the factor for the rate of character changes. A non-negative number. | 1.0 | | Characters | Restrict rain glyphs to a custom set (e.g. ATGC). Leave empty for the default. | *(empty)* | -| Fonts | Pick installed fonts for the rain glyphs (up to 8, mixed evenly). Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | +| Font | Pick one installed font for the rain glyphs. Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | | Neo message scene | Show the Neo message intro before the main scene. | On | | Typing speed | Typing speed multiplier for Neo messages, custom command entry and the trace program. A positive number; pauses keep their natural duration. Output is not typed. | 1.0 | | Name | Name used in the intro message ("Wake up, {name}..."). Only applies when Neo message lines is empty. | *(empty — uses "Neo")* | @@ -59,9 +59,11 @@ cancels its command. The sandboxed screen saver may not have the same access as ### Matrix fonts -The **Fonts** option lists installed font families, including hidden fonts discovered in the standard system and user font folders. Mix up to 8 fonts. Default and custom characters missing from a selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. No fonts are bundled, so install them yourself first. +The **Glyph set** radio buttons in **Rain** select **Default**, **Classic**, or **Resurrections**. Classic and Resurrections use their original bundled fonts and glyph artwork. Default keeps the existing configurable rain. Switching presets preserves your custom font and character settings; these controls are editable when Default is selected. -The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. +The **Font** option selects one installed font family, including hidden fonts discovered in the standard system and user font folders. Choosing another font replaces the selection; Reset restores the system font. Existing multi-font settings retain only the first font. Default and custom characters missing from the selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. Install any additional fonts yourself. + +The bundled [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. See [font provenance](Resources/Fonts/README.md) and the [included license](Resources/Fonts/LICENSE.txt). ### Wallpaper menubar menu diff --git a/Resources/Fonts/LICENSE.txt b/Resources/Fonts/LICENSE.txt new file mode 100644 index 0000000..8ed7dd3 --- /dev/null +++ b/Resources/Fonts/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Rezmason + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Resources/Fonts/Matrix-Code.ttf b/Resources/Fonts/Matrix-Code.ttf new file mode 100644 index 0000000000000000000000000000000000000000..95a3a5eec11b24e1ab6278c2b647b53f128765da GIT binary patch literal 7896 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 UBc3tt0~dn#?$apw*T3uj7ico~Q2+n{ literal 0 HcmV?d00001 diff --git a/Resources/Fonts/Matrix-Resurrected.ttf b/Resources/Fonts/Matrix-Resurrected.ttf new file mode 100644 index 0000000000000000000000000000000000000000..58dad207d15c17cb34fe8764a824804ff667350a GIT binary patch literal 20332 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 literal 0 HcmV?d00001 diff --git a/Resources/Fonts/README.md b/Resources/Fonts/README.md new file mode 100644 index 0000000..d169ca0 --- /dev/null +++ b/Resources/Fonts/README.md @@ -0,0 +1,116 @@ +# Rezmason Matrix fonts + +Original, unmodified TrueType fonts from [Rezmason/matrix](https://github.com/Rezmason/matrix), +pinned to commit `5ba90490453ceceb6812d6b1bc658a99a92411d0`. +Copyright (c) 2018 Rezmason. The upstream MIT license is reproduced verbatim in +`LICENSE.txt`; distribute it with the fonts. + +Upstream paths at that commit: + +- `assets/Matrix-Code.ttf` → `Matrix-Code.ttf` +- `assets/Matrix-Resurrected.ttf` → `Matrix-Resurrected.ttf` +- `LICENSE` → `LICENSE.txt` + +Download base: + +SHA-256 checksums: + +```text +5dff3023c91291d28dffc32fd74f5edbbca1969f25077d0499b1f000cabdec5b Matrix-Code.ttf +cad5cb197237f3eeed8d5d251df9c058caeb8179dc97494bb19447d2cf3ca4fc Matrix-Resurrected.ttf +65333257cebb87e8af86201a65f0e550d65b3cf5a468bfee2a69c9d2b5d21123 LICENSE.txt +``` + +## Verified character mappings + +These mappings were inspected directly from the TTF `cmap`, `name`, and `glyf` +tables with FontTools. All Unicode cmap subtables agree. Family, full, and +PostScript names are respectively `Matrix-Code` and `Matrix-Resurrected`; +both use the `Regular` subfamily. + +**Classic TTF repertoire:** 57 nonblank mapped glyphs. Their Unicode code points (hexadecimal) +are the following explicit set, not a contiguous range: + +```text +0022 002A 002B 0030 0031 0032 0033 0034 0035 0037 0038 0039 +003A 003C 003E 007A 007C 00A6 00A9 254C 25AA 30A2 30A6 30A8 +30AA 30AB 30AD 30B1 30B3 30B5 30B7 30B9 30BB 30BD 30BF 30C4 +30C6 30CA 30CB 30CC 30CD 30CF 30D2 30DB 30DE 30DF 30E0 30E1 +30E2 30E4 30E8 30E9 30EA 30EF 30FC A78A E937 +``` + +**Resurrections:** the TTF contains **138**, not 135, nonblank mapped glyphs: +the inclusive private-use range **U+E900–U+E989**. Every character maps to a +different glyph ID with nonzero-width and nonzero-height outline bounds. +Upstream `js/config.js` specifies 135 active slots for its separate MSDF texture. +Direct atlas comparison confirms those slots match **U+E900–U+E986**, in order. +U+E987, U+E988, and U+E989 contain artwork matching inactive atlas slots +153, 154, and 155; exclude them when reproducing the website's active set. +Do not infer the TTF repertoire or atlas ordering from upstream's 57-entry +`glyph order.txt`. + +Both fonts additionally map U+0000, U+0001, and U+0020 to separate glyph IDs +with identical degenerate outlines: three points at (0, 0). Those are blank, +despite their nonzero contour count. Exclude the controls; retain U+0020 only +for Classic's intentional blank atlas slot, described below. +Space has advance width 512; the two control characters have zero advance. +There are no shared-glyph-ID aliases in the Unicode cmap and no other exactly +identical coordinate/contour/flag outlines. The Classic font also has unmapped, +empty `.notdef` and `nonmarkingreturn` glyphs; Resurrections has an empty +`.notdef` glyph. Glyph counts therefore differ from drawable character counts. + +## Website atlas equivalence + +Compared the pinned upstream `assets/matrixcode_msdf.png` (512×512, 8×8 cells) +and `assets/resurrections_msdf.png` (832×768, 13×12 cells) against Pillow/FreeType +renders of every Unicode-mapped character in the corresponding TTF. Atlas +indices below are zero-based, row-major from the top left; each cell is 64×64. +The website uses the first 57 and 135 slots respectively. + +Extracted atlas masks with `median(R, G, B) > 127`, cropped each mask to its +ink bounds, resized to fit a 96×96 canvas **preserving aspect ratio**, and +compared intersection-over-union against similarly normalized TTF masks +rendered at 512 pixels. Every nonblank active slot had a unique best match. +Lowest winning IoU: Classic 0.8673, Resurrections 0.8524. Smallest winning +margin over the runner-up: Classic 0.1420, Resurrections 0.0808. Normalizing +width and height independently is unsuitable: it confuses squares with bars. + +**Classic website sequence (57 slots):** + +```text +30DB 30A2 0033 30A6 blank 30BB 00A6 003A +0022 E937 30DF 30E9 30EA 254C 30C4 30C6 +30CB 30CF 30BD 25AA 30FC 003C 003E 0030 +007C 002B 002A 30B3 30B7 30DE 30E0 30E1 +30E2 30A8 30E4 30AD 30AA 30AB 0037 30B1 +30B5 30B9 007A 0031 0035 0032 30E8 30BF +30EF 0034 30CD 30CC 30CA 0039 0038 30D2 +0030 +``` + +- Slot 4 is entirely black in the original RGB texture: a genuine blank slot. + Represent it explicitly as blank (or U+0020 in a fixed-cell renderer). +- Slots 23 and 56 have identical thresholded masks and both match U+0030. + Preserve both entries to reproduce the website's weighting. +- Thus the 57 slots comprise **55 distinct visible glyphs**, one duplicate + zero, and one blank. The visible set is the Classic TTF repertoire above + **minus U+00A9 and U+A78A**. U+A78A matches inactive atlas slot 57; + U+00A9 is absent from the active atlas set. + +**Resurrections website sequence:** exactly **U+E900–U+E986 inclusive**, +135 distinct visible glyphs, with slot `i` matching `U+E900 + i` for all +`0 <= i < 135`. All matches were checked, not inferred from the endpoints. +No active blank slot or repeated character mapping. Slots 135–152 are blank; +slots 153–155 match the three excluded TTF characters U+E987–U+E989. + +Atlas SHA-256 checksums (investigation inputs, not bundled assets): + +```text +1fd4ed52f8e9cc4af6eaad69d28cd289bf6486f15f00c952548ff0533d6a83ab matrixcode_msdf.png +f2bfe884b08cacf6c8be09573442d51c5dc5e97bbd8f86bcd86177d4e4b14c79 resurrections_msdf.png +``` + +Validation: downloaded font/license files were compared byte-for-byte with the pinned raw +upstream files, SHA-256 hashed, and parsed with FontTools checksum checking. +Native CoreText rendering and packaged-bundle loading are separate integration +checks and were not performed as part of this asset import. diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index c78f4e5..321af65 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -29,6 +29,8 @@ struct MatrixScreenSaverOptions: Equatable { static let characters = "Characters" /// Defaults key for the selected rain font families. static let rainFonts = "RainFonts" + /// Independent of custom characters and fonts so switching presets preserves both. + static let rainGlyphSet = "RainGlyphSet" static let scanLinesIntensity = "ScanLinesIntensity" static let scanLinesVertical = "ScanLinesVertical" } @@ -96,12 +98,10 @@ struct MatrixScreenSaverOptions: Equatable { 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." - /// Help text shown under the Fonts picker. - static let rainFontsDescription = "Mix up to 8 fonts." + /// Help text shown under the Font picker. + static let rainFontsDescription = "Choose a font for the default glyph set." /// Default selection: no fonts, which keeps the built-in glyphs and system font. static let defaultRainFonts: [String] = [] - /// Maximum number of rain fonts that can be mixed. - static let maximumRainFonts = 8 static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled @@ -127,8 +127,10 @@ struct MatrixScreenSaverOptions: Equatable { var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters - /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs. + /// Zero or one selected font; the array format preserves existing saved preferences. var rainFonts = defaultRainFonts + /// Bundled film glyphs or the existing configurable rain repertoire. + var rainGlyphSet: RainGlyphSet = .default var scanLinesIntensity = defaultScanLinesIntensity var scanLinesVertical = defaultScanLinesVertical @@ -168,7 +170,8 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, - rainFonts: rainFonts, + rainFonts: Array(rainFonts.prefix(1)), + rainGlyphSet: rainGlyphSet, neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview @@ -224,14 +227,11 @@ struct MatrixScreenSaverOptions: Equatable { .map { String($0) } .joined() }(), - rainFonts: { - var seen = Set() - return rainFonts - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && seen.insert($0).inserted } - .prefix(Self.maximumRainFonts) - .map { $0 } - }(), + rainFonts: Array(rainFonts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(1)), + rainGlyphSet: rainGlyphSet, scanLinesIntensity: max(0, min(1, scanLinesIntensity)), scanLinesVertical: scanLinesVertical ) @@ -261,6 +261,7 @@ struct MatrixScreenSaverOptions: Equatable { Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, Keys.rainFonts: defaultRainFonts, + Keys.rainGlyphSet: RainGlyphSet.default.rawValue, Keys.scanLinesIntensity: defaultScanLinesIntensity, Keys.scanLinesVertical: defaultScanLinesVertical, ]) @@ -290,6 +291,7 @@ struct MatrixScreenSaverOptions: Equatable { errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, rainFonts: (defaults.array(forKey: Keys.rainFonts) as? [String]) ?? defaultRainFonts, + rainGlyphSet: RainGlyphSet(rawValue: defaults.string(forKey: Keys.rainGlyphSet) ?? "") ?? .default, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical) ).sanitized() @@ -319,12 +321,18 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) defaults.set(options.rainFonts, forKey: Keys.rainFonts) + defaults.set(options.rainGlyphSet.rawValue, forKey: Keys.rainGlyphSet) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) defaults.set(options.scanLinesVertical, forKey: Keys.scanLinesVertical) defaults.synchronize() } } +/// A top-origin document keeps the Rain options at the top when its content grows. +private final class RainOptionsDocumentView: NSView { + override var isFlipped: Bool { true } +} + final class MatrixScreenSaverOptionsSheetController: NSObject, NSTextFieldDelegate, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { @@ -439,9 +447,15 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") - /// Pull-down listing installed fonts; ticked entries are the selection. + /// Pull-down listing installed fonts with one selected entry. private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) - /// Fonts currently ticked in the pull-down, in selection order. + private let rainFontsResetButton = NSButton(title: "Reset", target: nil, action: nil) + /// Radio buttons share a parent and action for native keyboard navigation. + private let rainGlyphRadios = RainGlyphSet.allCases.map { NSButton(radioButtonWithTitle: $0.title, target: nil, action: nil) } + /// Wrapping native text uses the same font fallback as the renderer. + private let rainGlyphPreview = NSTextField(wrappingLabelWithString: "") + private var selectedRainGlyphSet: RainGlyphSet = .default + /// Zero or one selected font, kept in the persisted array format. private var selectedRainFonts: [String] = [] private var didClose = false @@ -491,7 +505,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters - selectedRainFonts = options.rainFonts + selectedRainFonts = options.sanitized().rainFonts + selectedRainGlyphSet = options.rainGlyphSet + updateRainGlyphControls() rebuildRainFontsMenu() updateSceneControls() sizeWindowToFitContent() @@ -557,7 +573,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainDensityField.delegate = self rainDurationField.delegate = self frameRateField.delegate = self - errorRateField.delegate = self + errorRateField.delegate = self + charactersField.delegate = self rainRunForeverCheckbox.target = self rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:)) for checkbox in [neoMessageSceneCheckbox, cmdSceneCheckbox, numberSceneCheckbox] { @@ -599,8 +616,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), - makeRainDurationSection(), - makeRainFontsSection(), + makeRainDurationSection(), + makeRainGlyphSection(), + makeRainFontsSection(), makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), ])) @@ -1129,6 +1147,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Saves edits before scrolling recycles a row's text field. func controlTextDidChange(_ notification: Notification) { guard let field = notification.object as? NSTextField else { return } + if field === charactersField { + updateRainGlyphPreview() + return + } let row = linesTableView.row(for: field) guard editorLines.indices.contains(row) else { return } editorLines[row] = field.stringValue @@ -1232,6 +1254,64 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + private func makeRainGlyphSection() -> NSView { + let heading = NSTextField(labelWithString: "Glyph set") + heading.font = .systemFont(ofSize: 13, weight: .semibold) + for (index, radio) in rainGlyphRadios.enumerated() { + radio.tag = index + radio.target = self + radio.action = #selector(rainGlyphSetChanged(_:)) + } + let row = NSStackView(views: rainGlyphRadios) + row.spacing = 12 + rainGlyphPreview.drawsBackground = true + rainGlyphPreview.backgroundColor = .black + rainGlyphPreview.textColor = .green + rainGlyphPreview.lineBreakMode = .byCharWrapping + rainGlyphPreview.maximumNumberOfLines = 0 + rainGlyphPreview.setAccessibilityLabel("Selected rain glyphs") + let stack = NSStackView(views: [heading, row, rainGlyphPreview]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + rainGlyphPreview.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + row.toolTip = "Classic and Resurrections use their original bundled fonts. Default uses the settings below." + return stack + } + + @objc private func rainGlyphSetChanged(_ sender: NSButton) { + selectedRainGlyphSet = RainGlyphSet.allCases[sender.tag] + updateRainGlyphControls() + } + + /// Film glyphs require their matching font, so custom settings remain saved but inactive. + private func updateRainGlyphControls() { + for (preset, radio) in zip(RainGlyphSet.allCases, rainGlyphRadios) { + radio.state = preset == selectedRainGlyphSet ? .on : .off + } + let customEnabled = selectedRainGlyphSet == .default + rainFontsButton.isEnabled = customEnabled + rainFontsResetButton.isEnabled = customEnabled + charactersField.isEnabled = customEnabled + updateRainGlyphPreview() + } + + /// Reads the renderer's actual repertoire so custom filtering and preset weighting stay consistent. + private func updateRainGlyphPreview() { + var options = MatrixScreenSaverOptions() + options.rainGlyphSet = selectedRainGlyphSet + options.rainFonts = selectedRainFonts + options.characters = charactersField.stringValue + let renderer = NativeMatrixRenderer() + renderer.updateConfiguration(options.sanitized().rendererConfiguration()) + let name = renderer.activeRainFonts.first + rainGlyphPreview.font = name.flatMap { RainFontCatalog.font(named: $0, size: 12) } + ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) + rainGlyphPreview.stringValue = String(String.UnicodeScalarView(renderer.rainGlyphs)) + rainGlyphPreview.invalidateIntrinsicContentSize() + } + /// Builds the rain fonts picker with every discovered font family. private func makeRainFontsSection() -> NSView { let stack = NSStackView() @@ -1240,19 +1320,21 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.spacing = 4 stack.setHuggingPriority(.required, for: .vertical) - let heading = NSTextField(labelWithString: "Fonts") + let heading = NSTextField(labelWithString: "Font") heading.font = .systemFont(ofSize: 13, weight: .semibold) - rainFontsButton.setAccessibilityLabel("Fonts") + rainFontsButton.setAccessibilityLabel("Font") rainFontsButton.translatesAutoresizingMaskIntoConstraints = false rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail - let resetButton = NSButton(title: "Reset", target: self, action: #selector(clearRainFonts(_:))) + let resetButton = rainFontsResetButton + resetButton.target = self + resetButton.action = #selector(clearRainFonts(_:)) resetButton.bezelStyle = .rounded resetButton.controlSize = .small resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - resetButton.setAccessibilityLabel("Reset fonts to system default") + resetButton.setAccessibilityLabel("Reset font to system default") resetButton.toolTip = "Use the default system font" let row = NSStackView(views: [rainFontsButton, resetButton]) row.orientation = .horizontal @@ -1273,7 +1355,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, // Resolve the backing family instead of exposing the system alias or face weights. let backingFont = CTFontCreateWithGraphicsFont(CTFontCopyGraphicsFont(systemFont, nil), 16, nil, nil) let defaultTitle = "Default - \(CTFontCopyFamilyName(backingFont))" - let summary = selectedRainFonts.isEmpty ? defaultTitle : selectedRainFonts.joined(separator: ", ") + let summary = selectedRainFonts.first ?? defaultTitle menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "") @@ -1284,7 +1366,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) for family in families { - let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") + let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") item.target = self item.representedObject = family item.state = selectedRainFonts.contains(family) ? .on : .off @@ -1294,21 +1376,19 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainFontsButton.selectItem(at: 0) } - /// Adds or removes the chosen family, ignoring additions beyond the maximum. - @objc private func toggleRainFont(_ sender: NSMenuItem) { + /// Replaces the selected font without toggling an already selected font off. + @objc private func selectRainFont(_ sender: NSMenuItem) { guard let family = sender.representedObject as? String else { return } - if let index = selectedRainFonts.firstIndex(of: family) { - selectedRainFonts.remove(at: index) - } else if selectedRainFonts.count < MatrixScreenSaverOptions.maximumRainFonts { - selectedRainFonts.append(family) - } + selectedRainFonts = [family] rebuildRainFontsMenu() + updateRainGlyphPreview() } /// Clears the selection so the built-in glyphs and system font are used. @objc private func clearRainFonts(_ sender: Any?) { selectedRainFonts = [] rebuildRainFontsMenu() + updateRainGlyphPreview() } /// Builds a labeled text input row with its description. @@ -1435,8 +1515,21 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, item.label = title // Start at the real page size so NSTabView never lays the page out at its 20pt placeholder width, // which made the fixed-width rows report unsatisfiable constraints in the screen saver host. - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 550)) - item.view = contentView + let frame = NSRect(x: 0, y: 0, width: 520, height: 550) + let scrolls = title == "Rain" + let contentView = scrolls ? RainOptionsDocumentView(frame: frame) : NSView(frame: frame) + if scrolls { + let scrollView = NSScrollView(frame: frame) + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.documentView = contentView + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor).isActive = true + item.view = scrollView + } else { + item.view = contentView + } let stack = NSStackView(views: views) stack.orientation = .vertical stack.alignment = .leading @@ -1449,7 +1542,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, 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), + scrolls ? stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) + : stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), ]) for view in views { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true @@ -1520,6 +1614,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), rainFonts: selectedRainFonts, + rainGlyphSet: selectedRainGlyphSet, scanLinesIntensity: scanLinesSlider.doubleValue / 100, scanLinesVertical: scanLinesDirectionPopup.indexOfSelectedItem == 0 ).sanitized() diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 2babb18..02ba423 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -1,6 +1,61 @@ import AppKit +import CoreText import Foundation +/// Rain repertoires; film presets pair their original artwork with the matching bundled font. +enum RainGlyphSet: String, CaseIterable { + /// Existing built-in or user-customized rain. + case `default` + /// Original trilogy glyph artwork. + case classic + /// Expanded Matrix Resurrections glyph artwork. + case resurrections + + /// Display label in the Rain options radio group. + var title: String { + switch self { + case .default: return "Default" + case .classic: return "Classic" + case .resurrections: return "Resurrections" + } + } + + /// Bundled font resource name, also used as the rain cache's font identity. + var fontName: String? { + switch self { + case .default: return nil + case .classic: return "Matrix-Code" + case .resurrections: return "Matrix-Resurrected" + } + } + + /// Website atlas slots, including Classic's blank and repeated zero to preserve their probability. + /// Mapping provenance is recorded in Resources/Fonts/README.md; glyph order.txt is only approximate. + var scalars: [UnicodeScalar]? { + switch self { + case .default: return nil + case .classic: + return Array("ホア3ウ セ¦:\"\u{E937}ミラリ╌ツテニハソ▪ー<>0|+*コシマムメモエヤキオカ7ケサスz152ヨタワ4ネヌナ98ヒ0".unicodeScalars) + case .resurrections: + return (0xE900...0xE986).compactMap(UnicodeScalar.init) + } + } + + /// File-backed descriptors avoid globally registering or depending on installed font versions. + static let fontDescriptors: [String: NSFontDescriptor] = { + var fonts: [String: NSFontDescriptor] = [:] + let bundle = Bundle(for: NativeMatrixRenderer.self) + for preset in allCases { + guard let name = preset.fontName, + let url = bundle.url(forResource: name, withExtension: "ttf", subdirectory: "Fonts"), + let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor], + let descriptor = descriptors.first else { continue } + fonts[name] = descriptor as NSFontDescriptor + } + return fonts + }() +} + final class NativeMatrixRenderer { struct Configuration: Equatable { var neoMessageSceneEnabled = true @@ -24,6 +79,8 @@ final class NativeMatrixRenderer { var characters = "" /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs and system font. var rainFonts: [String] = [] + /// Film presets override rendering without discarding the user's custom fonts or characters. + var rainGlyphSet: RainGlyphSet = .default var neoMessageName = "" var neoMessageLines: [String] = [] /// When true the 10-second multi-screen sync delay is skipped so scenes @@ -53,6 +110,7 @@ final class NativeMatrixRenderer { errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, rainFonts: rainFonts, + rainGlyphSet: rainGlyphSet, neoMessageName: neoMessageName.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.lines(forName: neoMessageName) : neoMessageLines, skipSyncDelay: skipSyncDelay @@ -293,14 +351,15 @@ final class NativeMatrixRenderer { /// - Parameters: /// - fonts: Selected families, with duplicates ignored and at most eight used. /// - characters: Optional custom character restriction applied before sampling. + /// - glyphSet: Film presets supply their complete repertoire instead of sampling the font. /// - Returns: Disjoint private-use keys for every font, including shared characters. - static func makeRainTables(fonts: [String], characters: String = "") -> RainFontTables { + static func makeRainTables(fonts: [String], characters: String = "", glyphSet: RainGlyphSet = .default) -> RainFontTables { var tables = RainFontTables() var seen = Set() let names = fonts.filter { seen.insert($0).inserted }.prefix(8) for (fontIndex, name) in names.enumerated() { var pool = [UnicodeScalar]() - for (index, source) in glyphEntries(forFont: name, characters: characters).enumerated() { + for (index, source) in (glyphSet.scalars ?? glyphEntries(forFont: name, characters: characters)).enumerated() { // Each font owns separate keys so shared glyphs cannot steal another font's weight. let key = UnicodeScalar(0xF0000 + fontIndex * RainFontTables.maximumGlyphsPerFont + index)! pool.append(key) @@ -356,6 +415,11 @@ final class NativeMatrixRenderer { private var configuration = Configuration().sanitized() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool + /// Active rain characters in pool order, resolving private-use cache aliases for the options preview. + var rainGlyphs: [UnicodeScalar] { + activeGlyphPool.map { rainSourceByScalar[$0] ?? $0 } + } + /// When set, each rain glyph picks a selected font at random, then a glyph within it. private var activeMixPools: [[UnicodeScalar]]? = nil /// Glyph tables for the current `activeRainFonts`. @@ -604,9 +668,14 @@ final class NativeMatrixRenderer { pendingSimulationSteps = 0.0 } - if sanitizedConfiguration.rainFonts != activeRainFonts || sanitizedConfiguration.characters != previousConfiguration.characters { - activeRainFonts = sanitizedConfiguration.rainFonts - rainTables = Self.makeRainTables(fonts: activeRainFonts, characters: sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines)) + let glyphsChanged = sanitizedConfiguration.rainFonts != previousConfiguration.rainFonts || + sanitizedConfiguration.characters != previousConfiguration.characters || + sanitizedConfiguration.rainGlyphSet != previousConfiguration.rainGlyphSet + if glyphsChanged { + activeRainFonts = sanitizedConfiguration.rainGlyphSet.fontName.map { [$0] } ?? sanitizedConfiguration.rainFonts + rainTables = Self.makeRainTables(fonts: activeRainFonts, + characters: sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines), + glyphSet: sanitizedConfiguration.rainGlyphSet) rainFontByScalar = rainTables.fontByScalar rainSourceByScalar = rainTables.sourceByScalar } @@ -633,7 +702,7 @@ final class NativeMatrixRenderer { resetLayers() beginSceneSequence() } else if columns > 0, rows > 0, activeScene == .rainForever { - if previousConfiguration.rainFonts != sanitizedConfiguration.rainFonts || previousConfiguration.characters != sanitizedConfiguration.characters { + if glyphsChanged { resetLayers() } constructRenderContent() @@ -1712,6 +1781,9 @@ enum RainFontCatalog { /// Resolves a family, PostScript or full font name to a font. static func font(named name: String, size: CGFloat) -> NSFont? { + if let descriptor = RainGlyphSet.fontDescriptors[name] { + return NSFont(descriptor: descriptor, size: size) + } if fontFileInventory == nil { refreshHiddenFonts() } if let descriptor = hiddenFonts[name] { // A descriptor retains the file URL; resolving a hidden name alone may substitute Times. diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index ab55a72..e2636b7 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -3,6 +3,7 @@ import Foundation func nativeMatrixRendererTests() { rainFontTests() + rainGlyphPresetTests() commandCaptureLifecycleTests() customTraceHandoffTests() @@ -248,6 +249,34 @@ private func rainFontTests() { "RainFont: rendered rain mixes selected fonts evenly with fallback-enabled glyph pools") } +/// Exercises bundled preset coverage and switching back to the retained custom rain settings. +private func rainGlyphPresetTests() { + let renderer = NativeMatrixRenderer() + var config = NativeMatrixRenderer.Configuration() + config.rainFonts = ["Menlo"] + config.characters = "ATGC" + renderer.updateConfiguration(config) + let customSources = renderer.rainSourceByScalar + for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] { + config.rainGlyphSet = preset + renderer.updateConfiguration(config) + ok(renderer.rainSourceByScalar.count == count && renderer.activeRainFonts == [preset.fontName!], + "Rain presets: \(preset.rawValue) uses its complete bundled glyph set instead of custom characters") + let font = RainFontCatalog.font(named: preset.fontName!, size: 16) + let loadedURL = font.flatMap { CTFontCopyAttribute($0, kCTFontURLAttribute) as? URL } + let bundledURL = Bundle(for: NativeMatrixRenderer.self).url(forResource: preset.fontName!, withExtension: "ttf", subdirectory: "Fonts") + let sources = Array(renderer.rainSourceByScalar.values) + let coverage = font.map { CTFontCopyCharacterSet($0) as CharacterSet } + ok(loadedURL != nil && loadedURL?.resolvingSymlinksInPath() == bundledURL?.resolvingSymlinksInPath() && + sources.count == count && sources.allSatisfy { coverage?.contains($0) == true }, + "Rain presets: \(preset.rawValue) resolves every glyph from its bundled font") + } + config.rainGlyphSet = .default + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts == ["Menlo"] && renderer.rainSourceByScalar == customSources, + "Rain presets: Default restores the custom font and character pool") +} + /// Checks idle intro frames and cursor-only updates through the host's dirty-row contract. private func introDirtyRowTests() { for neo in [true, false] { diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 34da38b..c348cf0 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -5,6 +5,7 @@ import ScreenSaver func sharedSaverTests() { glyphCacheTests() rainFontResetTests() + rainGlyphOptionsTests() firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), @@ -139,15 +140,16 @@ func sharedSaverTests() { check(fontOptions.rainFonts.isEmpty, "rain fonts default to none, keeping the built-in glyphs") fontOptions.rainFonts = [" Matrix-Code ", "Matrix-Code", "", "Matrix-Resurrected"] + (1...10).map { "Font \($0)" } let cleanFonts = fontOptions.sanitized().rainFonts - check(cleanFonts.prefix(2) == ["Matrix-Code", "Matrix-Resurrected"] && Set(cleanFonts).count == cleanFonts.count, - "rain font sanitizing trims names and drops blanks and duplicates") - check(cleanFonts.count == MatrixScreenSaverOptions.maximumRainFonts, "rain font sanitizing caps the selection") + check(cleanFonts == ["Matrix-Code"], "rain font sanitizing retains only the first nonempty trimmed name") + defaults.set(["", " Menlo ", "Hiragino Sans"], forKey: "RainFonts") + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Menlo"], + "existing multi-font preferences migrate to the first nonempty font") fontOptions.rainFonts = ["Matrix-Code", "Matrix-Resurrected"] fontOptions.save(to: defaults) - check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], - "rain fonts persist through save and load") - check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], - "rain fonts reach the renderer configuration") + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code"], + "only one rain font persists through save and load") + check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code"], + "only one selected font reaches the renderer configuration") let original = MatrixScreenSaverOptions() var commandOptions = original @@ -259,8 +261,17 @@ private func rainFontResetTests() { window.contentView!.layoutSubtreeIfNeeded() let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } - check(reset?.controlSize == .small && picker?.title.contains("Menlo") == true, + check(reset?.controlSize == .small && picker?.title == "Menlo" && + picker?.menu?.items.filter { $0.state == .on }.count == 1, "font picker exposes a small reset button beside a custom selection") + for _ in 0..<2 { + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) { + menu.performActionForItem(at: index) + } + check(picker?.title == "Hiragino Sans" && picker?.menu?.items.filter { $0.state == .on }.count == 1 && + picker?.menu?.items.first { $0.title == "Menlo" }?.state == .off, + "choosing a font replaces the previous selection and selecting it again keeps it selected") + } reset?.performClick(nil) check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && picker?.menu?.items.dropFirst(3).allSatisfy { $0.state == .off } == true, @@ -270,6 +281,88 @@ private func rainFontResetTests() { "font reset applies the default font without changing other options") } +/// Exercises preset persistence, exclusive radio selection, cancellation and retained customization. +private func rainGlyphOptionsTests() { + let domain = "dev.patsch.MatrixWallpaper.glyph-tests.\(UUID().uuidString)" + let defaults = ScreenSaverDefaults(forModuleWithName: domain)! + defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() } + MatrixScreenSaverOptions.registerDefaults(in: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default, + "existing installations keep the default glyph set") + defaults.set("unknown-preset", forKey: "RainGlyphSet") + check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default, + "unknown persisted glyph sets safely use Default") + var options = MatrixScreenSaverOptions() + options.characters = "ATGC" + options.rainFonts = ["Menlo"] + for preset in RainGlyphSet.allCases { + options.rainGlyphSet = preset + options.save(to: defaults) + let loaded = MatrixScreenSaverOptions.load(from: defaults) + check(loaded.rainGlyphSet == preset && loaded.rendererConfiguration().rainGlyphSet == preset && + loaded.characters == "ATGC" && loaded.rainFonts == ["Menlo"], + "\(preset.title) persists and reaches the renderer without overwriting customization") + } + var applied: MatrixScreenSaverOptions? + let controller = MatrixScreenSaverOptionsSheetController { applied = $0 } + controller.prepare(using: options) + let window = controller.configureSheet() + let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first! + let rainTab = tabs.tabViewItems.first { $0.label == "Rain" }! + tabs.selectTabViewItem(rainTab) + let controls = buttons(in: rainTab.view!) + let radios = RainGlyphSet.allCases.compactMap { preset in controls.first { $0.title == preset.title } } + let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) + let characters: NSTextField? = stored("charactersField", in: controller) + let reset = controls.first { $0.title == "Reset" } + let preview: NSTextField? = stored("rainGlyphPreview", in: controller) + check(radios.count == 3 && radios.last?.state == .on && picker?.isEnabled == false, + "Rain restores the saved Resurrections radio with custom font controls inactive") + guard radios.count == 3 else { return } + for (index, radio) in radios.enumerated() { + radio.performClick(nil) + check(radios.enumerated().allSatisfy { $0.element.state == ($0.offset == index ? .on : .off) }, + "\(radio.title) is mutually exclusive with the other glyph presets") + check(picker?.isEnabled == (index == 0) && characters?.isEnabled == (index == 0) && reset?.isEnabled == (index == 0), + "\(radio.title) enables custom settings only for Default") + let expectedCount = [4, 57, 135][index] + check(preview?.stringValue.unicodeScalars.count == expectedCount && preview?.drawsBackground == true && + preview?.backgroundColor == .black && preview?.textColor == .green, + "\(radio.title) previews every active glyph in green on black") + let expectedFont = index == 0 ? "Menlo-Regular" : ["Matrix-Code", "Matrix-Resurrected"][index - 1] + check(preview?.font?.fontName == expectedFont, "\(radio.title) preview uses its current rendering font") + } + window.contentView!.layoutSubtreeIfNeeded() + check(preview != nil && preview!.frame.height > (preview!.font?.pointSize ?? 0) * 1.5 && (rainTab.view as? NSScrollView)?.hasVerticalScroller == true, + "long glyph sets wrap onto multiple lines in a scrollable Rain tab") + buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil) + check(applied?.rainGlyphSet == .resurrections && applied?.rainFonts == ["Menlo"] && applied?.characters == "ATGC", + "accepting a film preset keeps custom fonts and characters for switching back") + controller.prepare(using: applied!) + radios[0].performClick(nil) + check(characters?.stringValue == "ATGC" && picker?.title.contains("Menlo") == true, + "switching back to Default restores editable custom settings") + characters?.stringValue = "XYZ" + controller.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: characters)) + check(preview?.stringValue == "XYZ", "editing custom characters refreshes the glyph preview immediately") + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) { + menu.performActionForItem(at: index) + } + check(preview?.font?.familyName == "Hiragino Sans", "choosing another font refreshes the preview typeface") + reset?.performClick(nil) + check(preview?.font?.fontName == NSFont.monospacedSystemFont(ofSize: 16, weight: .medium).fontName, + "font Reset restores the system typeface in the preview") + var cancelled = false + let cancelController = MatrixScreenSaverOptionsSheetController { _ in cancelled = true } + cancelController.prepare(using: options) + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Reset to defaults" }?.performClick(nil) + let resetRadios: [NSButton] = stored("rainGlyphRadios", in: cancelController) ?? [] + check(resetRadios.first?.state == .on, "Reset to defaults restores the default glyph set") + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Cancel" }?.performClick(nil) + check(!cancelled && MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .resurrections, + "Cancel does not persist a changed glyph preset") +} + /// Checks raster reuse through layout and option updates, retaining images to compare identity safely. private func glyphCacheTests() { let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)! @@ -368,6 +461,26 @@ private func glyphCacheTests() { } else { check(false, "wide selected-font glyphs fit inside the cell without cutting solid strokes") } + for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] { + let previousRaster = glyph(rainKey) + options.rainGlyphSet = preset + view.applyWallpaperOptions(options) + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view)! + let resolved: [String: NSFont] = stored("rainFonts", in: view) ?? [:] + let font = resolved[preset.fontName!] + let usesOriginalFont = font.map { CTFontCopyPostScriptName($0) as String == preset.fontName } ?? false + check(usesOriginalFont && renderer.rainSourceByScalar.count == count && + renderer.rainSourceByScalar.keys.allSatisfy { glyph($0) != nil } && previousRaster !== glyph(rainKey), + "\(preset.title) rebuilds all preset rasters using the original bundled font") + let cached = glyph(rainKey) + view.applyWallpaperOptions(options) + check(cached != nil && cached === glyph(rainKey) && cached === glyph(rainKey, bold: true), + "\(preset.title) reuses cached artwork across unchanged options and font weights") + } + options.rainGlyphSet = .default + view.applyWallpaperOptions(options) + check(glyph(UnicodeScalar(0xF0001)!) == nil && glyph(rainKey) != nil, + "returning to custom rain removes stale film-preset glyphs") var capturedOptions = MatrixScreenSaverOptions() capturedOptions.neoMessageSceneEnabled = false capturedOptions.cmdSceneEnabled = true @@ -412,16 +525,20 @@ private func checkOptionsLayout(in window: NSWindow) { for item in tabs.tabViewItems { tabs.selectTabViewItem(item) content.layoutSubtreeIfNeeded() - let page = item.view! + let page = (item.view as? NSScrollView)?.documentView ?? item.view! let controls = descendants(of: page).compactMap { $0 as? NSControl } check(!controls.isEmpty && controls.allSatisfy { page.bounds.contains($0.convert($0.bounds, to: page)) }, "\(item.label) keeps every control and help label inside its page") let sections = (page.subviews.first as? NSStackView)?.arrangedSubviews ?? [] - let top = sections.map { $0.convert($0.bounds, to: page).maxY }.max() ?? 0 - check((12...20).contains(page.bounds.maxY - top), "\(item.label) starts with a consistent small top margin") + let frames = sections.map { $0.convert($0.bounds, to: page) } + let topMargin = page.isFlipped ? (frames.map(\.minY).min() ?? 0) - page.bounds.minY + : page.bounds.maxY - (frames.map(\.maxY).max() ?? 0) + check((12...20).contains(topMargin), "\(item.label) starts with a consistent small top margin") if let stack = page.subviews.first as? NSStackView { let sections = stack.arrangedSubviews - check(zip(sections, sections.dropFirst()).allSatisfy { $0.frame.minY >= $1.frame.maxY }, + check(zip(sections, sections.dropFirst()).allSatisfy { + stack.isFlipped ? $0.frame.maxY <= $1.frame.minY : $0.frame.minY >= $1.frame.maxY + }, "\(item.label) sections do not overlap") } if let forever = controls.compactMap({ $0 as? NSButton }).first(where: { $0.title == "Run forever" }), diff --git a/build-wallpaper.sh b/build-wallpaper.sh index 4603728..9d5ef1c 100755 --- a/build-wallpaper.sh +++ b/build-wallpaper.sh @@ -36,6 +36,8 @@ swiftc -O -module-name MatrixWallpaper \ -o "$BUNDLE_DIR/Contents/MacOS/MatrixWallpaper" cp "$ROOT_DIR/Resources/Wallpaper-Info.plist" "$BUNDLE_DIR/Contents/Info.plist" +mkdir -p "$BUNDLE_DIR/Contents/Resources/Fonts" +cp "$ROOT_DIR/Resources/Fonts/"* "$BUNDLE_DIR/Contents/Resources/Fonts/" cp "$ROOT_DIR/Resources/WallpaperIcon.icns" "$ROOT_DIR/Resources/MenuBarIcon.pdf" "$BUNDLE_DIR/Contents/Resources/" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist" diff --git a/build.sh b/build.sh index 7ed2ca3..4db86f1 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,7 @@ swiftc \ "$SOURCE_DIR/MatrixScreenSaverView.swift" cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" +cp -R "$INPUT_RESOURCES_DIR/Fonts" "$RESOURCES_DIR/Fonts" for f in Preview.png "Preview@2x.png"; do if [[ -f "$INPUT_RESOURCES_DIR/$f" ]]; then cp "$INPUT_RESOURCES_DIR/$f" "$RESOURCES_DIR/$f" diff --git a/tests.sh b/tests.sh index 196e6a2..3a04d22 100755 --- a/tests.sh +++ b/tests.sh @@ -4,7 +4,13 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" SRC="$ROOT/Sources/MatrixScreenSaver" TESTS="$ROOT/Tests/MatrixScreenSaverTests" -TEST_BIN=/tmp/matrix_screensaver_tests +# A bundle exercises the same font-resource lookup used by both shipping products. +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-screensaver-tests.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents" +TEST_BIN="$TEST_CONTENTS/MacOS/matrix_screensaver_tests" +mkdir -p "$TEST_CONTENTS/MacOS" "$TEST_CONTENTS/Resources" +cp -R "$ROOT/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts" swiftc \ -framework Foundation \ diff --git a/wallpaper-tests.sh b/wallpaper-tests.sh index a55328a..2f833fa 100755 --- a/wallpaper-tests.sh +++ b/wallpaper-tests.sh @@ -5,9 +5,14 @@ ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_DIR="$ROOT_DIR/Sources/MatrixScreenSaver" WALLPAPER_DIR="$ROOT_DIR/Sources/MatrixWallpaper" TEST_DIR="$ROOT_DIR/Tests/MatrixWallpaperTests" -TEST_BIN="$(mktemp "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")" -HOST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-consumer-tests.XXXXXX")" -trap 'rm -f "$TEST_BIN" "$HOST_DIR/ScreenSaverEngine"; rmdir "$HOST_DIR"' EXIT +# Keep bundled font lookup identical in the app tests and the consumer-host copy. +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents" +HOST_DIR="$TEST_CONTENTS/MacOS" +TEST_BIN="$HOST_DIR/matrix-wallpaper-tests" +mkdir -p "$HOST_DIR" "$TEST_CONTENTS/Resources" +cp -R "$ROOT_DIR/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts" OPTIMIZATION="-Onone" if [[ "${1:-}" == "--gpu-composition-benchmark" || "${1:-}" == "--composition-benchmark" ]]; then From 81ddef79ef7bb01fee6024e02f8105b26faf20f0 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:36:20 +0100 Subject: [PATCH 7/8] feat: list bundled fonts below the system default - show both bundled fonts before the dropdown divider - avoid duplicate entries among installed fonts - verify selection, ordering and reset behavior --- .../MatrixScreenSaverOptions.swift | 21 +++++++++++-------- .../SharedSaverTests.swift | 14 ++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 321af65..e4b2946 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1362,15 +1362,18 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, defaultItem.target = self defaultItem.state = selectedRainFonts.isEmpty ? .on : .off menu.addItem(defaultItem) - menu.addItem(.separator()) - - let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) - for family in families { - let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") - item.target = self - item.representedObject = family - item.state = selectedRainFonts.contains(family) ? .on : .off - menu.addItem(item) + + let bundledFonts = RainGlyphSet.allCases.compactMap(\.fontName) + let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts).filter { !bundledFonts.contains($0) } + for (index, group) in [bundledFonts, families].enumerated() { + if index == 1 { menu.addItem(.separator()) } + for family in group { + let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") + item.target = self + item.representedObject = family + item.state = selectedRainFonts.contains(family) ? .on : .off + menu.addItem(item) + } } rainFontsButton.menu = menu rainFontsButton.selectItem(at: 0) diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index c348cf0..3f585c7 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -261,6 +261,11 @@ private func rainFontResetTests() { window.contentView!.layoutSubtreeIfNeeded() let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } + let bundledNames = ["Matrix-Code", "Matrix-Resurrected"] + let items = picker?.menu?.items ?? [] + check(items.count > 4 && Array(items.dropFirst(2).prefix(2).map(\.title)) == bundledNames && items[4].isSeparatorItem && + bundledNames.allSatisfy { name in items.filter { $0.representedObject as? String == name }.count == 1 }, + "bundled fonts appear once directly after Default and before the divider") check(reset?.controlSize == .small && picker?.title == "Menlo" && picker?.menu?.items.filter { $0.state == .on }.count == 1, "font picker exposes a small reset button beside a custom selection") @@ -272,9 +277,16 @@ private func rainFontResetTests() { picker?.menu?.items.first { $0.title == "Menlo" }?.state == .off, "choosing a font replaces the previous selection and selecting it again keeps it selected") } + for name in bundledNames { + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == name }) { + menu.performActionForItem(at: index) + } + check(picker?.title == name && picker?.menu?.items.filter { $0.state == .on }.count == 1, + "bundled font \(name) can be selected from the dropdown") + } reset?.performClick(nil) check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && - picker?.menu?.items.dropFirst(3).allSatisfy { $0.state == .off } == true, + picker?.menu?.items.filter { $0.representedObject is String }.allSatisfy { $0.state == .off } == true, "font reset restores the system-font summary and clears custom-font checkmarks") buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil) check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42, From 1fa958dfbe1ac1e226a7e8c547be1fe890052c8b Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:58:06 +0100 Subject: [PATCH 8/8] test: allow recovery fixtures to finish queued startup - match the existing ten-second AppKit startup allowance - stop and clean up fixtures when startup fails --- Tests/MatrixWallpaperTests/WallpaperAppTests.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index b42953b..444aafb 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -373,7 +373,13 @@ private func wallpaperTerminationRecoveryTests() { let controller = desktop.controller() let host = WallpaperApp(defaults: defaults, desktopWallpaper: controller) host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) - check(eventually { desktopWindowCount() == NSScreen.screens.count }, "recovery fixture awaits launch playback") + // Match the background-failure fixture's allowance for queued AppKit startup on CI. + let started = eventually(10) { desktopWindowCount() == NSScreen.screens.count } + check(started, "recovery fixture awaits launch playback") + guard started else { + host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + return + } desktop.unreadable = ["first"] host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) check(NSApp.modalWindow == nil && desktopWindowCount() == 0, @@ -385,12 +391,14 @@ private func wallpaperTerminationRecoveryTests() { desktop.images["first"] = .init(url: URL(fileURLWithPath: "/new-after-failed-quit.jpg"), options: [:]) let relaunched = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller()) relaunched.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) - check(eventually { desktopWindowCount() == NSScreen.screens.count }, "relaunch awaits recovery and playback") + defer { relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + let restarted = eventually(10) { desktopWindowCount() == NSScreen.screens.count } + check(restarted, "relaunch awaits recovery and playback") + guard restarted else { return } check(desktop.images.values.allSatisfy { $0.url == controller.blackImageURL }, "relaunch recovers stale entries and starts black") NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: relaunched, from: nil) check(desktop.images["first"]?.url.path == "/new-after-failed-quit.jpg", "relaunch captures a newer user choice instead of restoring a stale original") - relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } /// Exercises installation discovery and persistent menu state without touching real installations.