diff --git a/README.md b/README.md index f8fb2d6..75040ad 100644 --- a/README.md +++ b/README.md @@ -40,6 +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)* | +| 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")* | @@ -56,6 +57,14 @@ including while the screen is locked. Command text and output are not logged. Li until exit and retain at most 64 KiB of combined output; stopping or restarting playback cancels its command. The sandboxed screen saver may not have the same access as the wallpaper app. +### Matrix fonts + +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 **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 ![menubar.jpg](docs/wallpaper-menubar.jpg) @@ -67,7 +76,7 @@ cancels its command. The sandboxed screen saver may not have the same access as - 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, custom shell commands/playback, 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, custom shell commands/playback, and Scan lines - Preview app for fast iteration without reinstalling ### Repository layout 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 0000000..95a3a5e Binary files /dev/null and b/Resources/Fonts/Matrix-Code.ttf differ diff --git a/Resources/Fonts/Matrix-Resurrected.ttf b/Resources/Fonts/Matrix-Resurrected.ttf new file mode 100644 index 0000000..58dad20 Binary files /dev/null and b/Resources/Fonts/Matrix-Resurrected.ttf differ 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 72bbce8..e4b2946 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1,4 +1,5 @@ import AppKit +import CoreText import ScreenSaver struct MatrixScreenSaverOptions: Equatable { @@ -26,6 +27,10 @@ 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" + /// Independent of custom characters and fonts so switching presets preserves both. + static let rainGlyphSet = "RainGlyphSet" static let scanLinesIntensity = "ScanLinesIntensity" static let scanLinesVertical = "ScanLinesVertical" } @@ -89,11 +94,14 @@ 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 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] = [] static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled @@ -119,6 +127,10 @@ struct MatrixScreenSaverOptions: Equatable { var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters + /// 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 @@ -158,6 +170,8 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + rainFonts: Array(rainFonts.prefix(1)), + rainGlyphSet: rainGlyphSet, neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview @@ -213,6 +227,11 @@ struct MatrixScreenSaverOptions: Equatable { .map { String($0) } .joined() }(), + rainFonts: Array(rainFonts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(1)), + rainGlyphSet: rainGlyphSet, scanLinesIntensity: max(0, min(1, scanLinesIntensity)), scanLinesVertical: scanLinesVertical ) @@ -241,6 +260,8 @@ struct MatrixScreenSaverOptions: Equatable { Keys.frameRate: defaultFrameRate, Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, + Keys.rainFonts: defaultRainFonts, + Keys.rainGlyphSet: RainGlyphSet.default.rawValue, Keys.scanLinesIntensity: defaultScanLinesIntensity, Keys.scanLinesVertical: defaultScanLinesVertical, ]) @@ -269,6 +290,8 @@ 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, + rainGlyphSet: RainGlyphSet(rawValue: defaults.string(forKey: Keys.rainGlyphSet) ?? "") ?? .default, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical) ).sanitized() @@ -297,12 +320,19 @@ 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.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 { @@ -417,6 +447,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") + /// Pull-down listing installed fonts with one selected entry. + private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) + 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 /// Creates native options UI with a host-owned apply action. @@ -465,6 +505,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters + selectedRainFonts = options.sanitized().rainFonts + selectedRainGlyphSet = options.rainGlyphSet + updateRainGlyphControls() + rebuildRainFontsMenu() updateSceneControls() sizeWindowToFitContent() } @@ -529,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] { @@ -571,7 +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(), + makeRainDurationSection(), + makeRainGlyphSection(), + makeRainFontsSection(), makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), ])) @@ -581,7 +628,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: 520), + tabs.heightAnchor.constraint(equalToConstant: 550), tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), ]) @@ -1100,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 @@ -1203,6 +1254,146 @@ 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() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let heading = NSTextField(labelWithString: "Font") + heading.font = .systemFont(ofSize: 13, weight: .semibold) + + rainFontsButton.setAccessibilityLabel("Font") + rainFontsButton.translatesAutoresizingMaskIntoConstraints = false + rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true + (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail + + 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 font 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(row) + stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) + return stack + } + + /// Rebuilds the full font menu with the current selection. + private func rebuildRainFontsMenu() { + let menu = NSMenu() + 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.first ?? defaultTitle + menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) + + let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "") + defaultItem.target = self + defaultItem.state = selectedRainFonts.isEmpty ? .on : .off + menu.addItem(defaultItem) + + 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) + } + + /// 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 } + 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. private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { let stack = NSStackView() @@ -1271,7 +1462,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.addArrangedSubview(checkboxRow) stack.addArrangedSubview(secondsRow) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription)) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) return stack } @@ -1326,8 +1516,23 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private func makeOptionsTab(title: String, views: [NSView]) -> NSTabViewItem { let item = NSTabViewItem(identifier: title) item.label = title - let contentView = NSView() - item.view = contentView + // 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 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 @@ -1340,7 +1545,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 @@ -1410,6 +1616,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRate: frameRate, 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/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index e320798..c938184 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] = [:] @@ -111,6 +122,10 @@ 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] + /// Sources can change while private-use keys stay the same after editing Characters. + let rainSources: [UnicodeScalar: UnicodeScalar] } /// Inputs used by the current regular and bold rasters. private var nativeGlyphCacheKey: GlyphCacheKey? @@ -492,6 +507,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() @@ -946,11 +962,14 @@ 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, + rainSources: nativeRenderer.rainSourceByScalar) guard nativeGlyphCacheKey != key else { return } nativeSupplementalGlyphs.removeAll(keepingCapacity: true) - 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, + sharingRainGlyphs: nativeRegularGlyphsByLevel) nativeGlyphCacheKey = key } @@ -966,9 +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 + font: NSFont, + rainFonts: [String: NSFont], + sharingRainGlyphs: [[UnicodeScalar: CGImage]] = [] ) -> [[UnicodeScalar: CGImage]] { var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count) let scalars = nativeRenderer.supportedScalars @@ -979,7 +1007,15 @@ 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] { + 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 { continue } caches[level][scalar] = image @@ -989,8 +1025,15 @@ 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, attributes: [NSAttributedString.Key: Any] ) -> CGImage? { let pixelWidth = max(Int(ceil(cellWidth)), 1) @@ -1014,7 +1057,20 @@ final class MatrixScreenSaverView: ScreenSaverView { return glyphContext.makeImage() } - let text = String(scalar) + let text = String(drawing ?? scalar) + if drawing != nil { + let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + let ink = CTLineGetImageBounds(line, glyphContext) + guard !ink.isEmpty, !ink.isNull else { return glyphContext.makeImage() } + // Selected fonts can be wider or taller than the system font used to size the grid. + let scale = min(1, max(cellWidth - 2, 1) / ink.width, max(lineHeight - 2, 1) / ink.height) + glyphContext.translateBy(x: (cellWidth - ink.width * scale) / 2 - ink.minX * scale, + y: (lineHeight - ink.height * scale) / 2 - ink.minY * scale) + glyphContext.scaleBy(x: scale, y: scale) + glyphContext.textPosition = .zero + CTLineDraw(line, glyphContext) + return glyphContext.makeImage() + } 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 51ec0e6..02ba423 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -1,5 +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 @@ -21,6 +77,10 @@ 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] = [] + /// 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 @@ -49,6 +109,8 @@ final class NativeMatrixRenderer { frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), 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 @@ -229,9 +291,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) }) @@ -239,13 +304,80 @@ 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, selected with equal probability across fonts. + var pools: [[UnicodeScalar]] = [] + /// Font family each glyph key is drawn with. + var fontByScalar: [UnicodeScalar: String] = [:] + /// Private-use glyph key to the character drawn in its selected font. + var sourceByScalar: [UnicodeScalar: UnicodeScalar] = [:] + /// Upper bound on glyphs taken from one font, sampled evenly, to keep the glyph cache small. + static let maximumGlyphsPerFont = 512 + } + + /// 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` 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) || !characters.isEmpty || defaults.contains(sc), + v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588, + !CharacterSet.whitespacesAndNewlines.contains(sc), + !CharacterSet.controlCharacters.contains(sc) else { continue } + out.append(sc) + } + let limit = RainFontTables.maximumGlyphsPerFont + guard out.count > limit else { return out } + // Keep the default repertoire intact instead of sampling away characters that need fallback. + let required = characters.isEmpty ? out.filter { defaults.contains($0) } : [] + let requiredSet = Set(required) + let remaining = out.filter { !requiredSet.contains($0) } + let count = limit - required.count + let step = Double(remaining.count) / Double(count) + return required + (0.. 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 (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) + tables.fontByScalar[key] = name + tables.sourceByScalar[key] = 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]() @@ -257,7 +389,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. @@ -283,6 +415,21 @@ 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`. + 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) @@ -521,7 +668,23 @@ final class NativeMatrixRenderer { pendingSimulationSteps = 0.0 } - activeGlyphPool = Self.resolveGlyphPool(characters: sanitizedConfiguration.characters) + 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 + } + let fontPoolUnion = rainTables.pools.flatMap { $0 } + activeGlyphPool = fontPoolUnion.isEmpty ? Self.resolveGlyphPool( + characters: sanitizedConfiguration.characters, + fallback: Self.defaultGlyphPool + ) : fontPoolUnion + activeMixPools = rainTables.pools.count > 1 ? rainTables.pools : nil let shouldResetSceneSequence = columns > 0 && @@ -539,6 +702,9 @@ final class NativeMatrixRenderer { resetLayers() beginSceneSequence() } else if columns > 0, rows > 0, activeScene == .rainForever { + if glyphsChanged { + resetLayers() + } constructRenderContent() } markAllRowsDirty() @@ -1330,7 +1496,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. @@ -1560,3 +1731,76 @@ final class NativeMatrixRenderer { return result } } + +/// Lists installed font families and resolves them for rain rendering. +enum RainFontCatalog { + /// File-backed families omitted by the system's selectable-font enumeration. + private static var hiddenFonts: [String: NSFontDescriptor] = [:] + /// Modification dates avoid reparsing font files on every menu click. + private static var fontFileInventory: [URL: Date]? + + /// Discovers font metadata in the standard system, local, network, and user font folders. + private static func refreshHiddenFonts() { + let manager = FileManager.default + let roots = manager.urls(for: .libraryDirectory, in: .allDomainsMask).map { $0.appendingPathComponent("Fonts") } + let keys: [URLResourceKey] = [.isRegularFileKey, .contentModificationDateKey] + var inventory: [URL: Date] = [:] + for root in roots { + guard let files = manager.enumerator(at: root, includingPropertiesForKeys: keys, options: [.skipsPackageDescendants]) else { continue } + for case let url as URL in files { + guard ["ttf", "otf", "ttc", "otc", "dfont", "suit"].contains(url.pathExtension.lowercased()), + let values = try? url.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { continue } + inventory[url] = values.contentModificationDate ?? .distantPast + } + } + guard inventory != fontFileInventory else { return } + let publicFamilies = Set(NSFontManager.shared.availableFontFamilies) + var discovered: [String: NSFontDescriptor] = [:] + for url in inventory.keys.sorted(by: { $0.path < $1.path }) { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { continue } + for descriptor in descriptors { + guard let family = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String, + !publicFamilies.contains(family) else { continue } + let candidate = descriptor as NSFontDescriptor + if let current = discovered[family], fontStyleScore(current) <= fontStyleScore(candidate) { continue } + discovered[family] = candidate + } + } + hiddenFonts = discovered + fontFileInventory = inventory + } + + /// Prefers upright faces with weight nearest regular when a file contains multiple styles. + /// - Parameter descriptor: File-backed face to rank. + /// - Returns: A lower score for a more neutral style. + private static func fontStyleScore(_ descriptor: NSFontDescriptor) -> 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? { + 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. + return NSFont(descriptor: descriptor, size: size) + } + return NSFont(name: name, size: size) + ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size) + } + + /// 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 99539b1..e2636b7 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -1,6 +1,10 @@ +import AppKit import Foundation func nativeMatrixRendererTests() { + rainFontTests() + rainGlyphPresetTests() + commandCaptureLifecycleTests() customTraceHandoffTests() customShellCommandSequenceTests() @@ -135,6 +139,144 @@ func nativeMatrixRendererTests() { 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"]) + 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(tables.pools.flatMap { $0 }.allSatisfy { $0.value >= 0xF0000 && tables.sourceByScalar[$0] != nil }, + "RainFont: every glyph is aliased so Unicode intro text keeps the system font") + let mixed = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo", "Monaco"], characters: "ABé") + ok(mixed.pools.count == 2 && mixed.pools.allSatisfy { pool in + Set(pool.compactMap { mixed.sourceByScalar[$0] }) == Set("ABé".unicodeScalars) + } && Set(mixed.pools.flatMap { $0 }).count == 6, + "RainFont: overlapping ASCII and Unicode characters remain available in every selected 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(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() + 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.characters = "A" + renderer.updateConfiguration(config) + ok(Set(renderer.rainSourceByScalar.values) == Set("A".unicodeScalars), + "RainFont: custom characters use the selected font") + config.characters = "B" + renderer.updateConfiguration(config) + ok(Set(renderer.rainSourceByScalar.values) == Set("B".unicodeScalars), + "RainFont: editing characters rebuilds the font table without changing fonts") + config.rainFonts = [] + 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") +} + +/// 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 8400464..3f585c7 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -4,6 +4,8 @@ import ScreenSaver /// Exercises the shared view and options controller through their hosting boundaries. func sharedSaverTests() { glyphCacheTests() + rainFontResetTests() + rainGlyphOptionsTests() firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), @@ -134,6 +136,21 @@ 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 == ["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"], + "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 commandOptions.commands = [String(repeating: "x", count: 513), "printf safe", "printf bad\0ignored"] @@ -229,6 +246,135 @@ 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" } + 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") + 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") + } + 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.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, + "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)! @@ -248,6 +394,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, @@ -268,6 +416,83 @@ private func glyphCacheTests() { check(glyph("A") != nil && glyph("A", bold: true) != nil, "restarting after resource cleanup recreates usable glyph caches") view.stopAnimation() + options.rainFonts = ["Menlo"] + options.characters = "A" + 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 + options.characterHeight = 30 + view.applyWallpaperOptions(options) + if let image = glyph(rainKey) { + let bitmap = NSBitmapImageRep(cgImage: image) + // Core Text antialiasing can reach past its measured ink bounds; solid strokes must not. + let edgesHaveNoSolidInk = (0.. 0 } + } + check(edgesHaveNoSolidInk && hasInk, "wide selected-font glyphs fit inside the cell without cutting solid strokes") + } 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 @@ -275,6 +500,7 @@ private func glyphCacheTests() { capturedOptions.commands = ["printf '\\303\\251'"] capturedOptions.neoMessageSpeedFactor = 100 capturedOptions.scanLinesIntensity = 0 + capturedOptions.rainFonts = ["Menlo"] let container = NSView(frame: view.frame) container.addSubview(view) defer { view.removeFromSuperview() } @@ -299,7 +525,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") @@ -310,16 +537,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/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. 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