diff --git a/slab/bin/slab-video b/slab/bin/slab-video index 9f0a11bdce..1485a11b70 100755 --- a/slab/bin/slab-video +++ b/slab/bin/slab-video @@ -1,18 +1,31 @@ #!/usr/bin/env bash # slab-video [--playlist] [more.mov …] — open videos in slab's # minimal player. Without --playlist, appends absolute paths to -# $SLAB_HOME/state/open-video and raises one chromeless AVKit panel per movie. -# With --playlist, writes one atomic ordered queue request and raises one panel -# that advances through every movie (left/right changes tracks). Re-asking for -# an open source brings its window forward and restarts playback. Single-video -# panels watch the file and reload on rewrite. See -# slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift. +# $SLAB_HOME/state/open-video and raises one chromeless AVKit panel per movie; +# a second open panel grids the wall automatically. With --playlist, writes one +# atomic ordered queue request and raises one panel with the queue listed +# beside the picture (click a row to jump; left/right also change tracks; it +# advances on its own). Re-asking for an open source brings its window forward +# and restarts playback. Single-video panels watch the file and reload on +# rewrite. +# +# slab-video --close-all close every open panel +# slab-video --tile re-grid the open panels +# +# See slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift. set -euo pipefail SLAB_HOME="${SLAB_HOME:-$HOME/.local/share/slab}" REQUEST="$SLAB_HOME/state/open-video" PLAYLIST_REQUESTS="$SLAB_HOME/state/open-video-playlists" +mkdir -p "$SLAB_HOME/state" "$PLAYLIST_REQUESTS" + +case "${1:-}" in + --close-all) touch "$SLAB_HOME/state/close-video"; exit 0 ;; + --tile) touch "$SLAB_HOME/state/tile-video"; exit 0 ;; +esac + playlist=false if [ "${1:-}" = "--playlist" ]; then playlist=true @@ -20,12 +33,10 @@ if [ "${1:-}" = "--playlist" ]; then fi if [ $# -eq 0 ]; then - echo "usage: slab-video [--playlist] [more.mov …]" >&2 + echo "usage: slab-video [--playlist] [more.mov …] | --close-all | --tile" >&2 exit 1 fi -mkdir -p "$SLAB_HOME/state" "$PLAYLIST_REQUESTS" - # Which Claude session asked? Walk up the process tree to the claude pid # (same loop as claude-prompt-log.sh) and match it against the live # active-prompts markers — the viewer chip then wears that session's diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index fbff6bf0ea..6488be65f2 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -938,6 +938,7 @@ enum MenuBuilder { sub.addItem(row) } sub.addItem(.separator()) + sub.addItem(item("Tile all", selector: #selector(AppDelegate.tileAllVideos), target: target)) sub.addItem(item("Close all", selector: #selector(AppDelegate.closeAllVideos), target: target)) } parent.submenu = sub diff --git a/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift b/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift index 84995f8993..198e36f054 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift @@ -12,6 +12,12 @@ // writes either form. Re-requesting the same source brings its window forward // and restarts playback. Single-video windows watch their file and reload on // rewrite, so a render loop (build.mjs → same mp4 path) feels live. +// +// Managing a wall of them: touching $SLAB_HOME/state/close-video closes every +// panel, $SLAB_HOME/state/tile-video re-grids them; opening a second panel +// grids them automatically (the ImageGroupPreview layout). A playlist panel +// carries its queue as a list beside the picture — click a row to jump, the +// playing row is highlighted, and the queue advances on its own. import AppKit import AVKit import AVFoundation @@ -24,6 +30,9 @@ extension Paths { static var videoPlaylistRequestDirectory: String { "\(slabHome)/state/open-video-playlists" } + /// Sentinels: their presence is the ask; consumed (deleted) each tick. + static var videoCloseFile: String { "\(slabHome)/state/close-video" } + static var videoTileFile: String { "\(slabHome)/state/tile-video" } } struct OpenVideoItem { @@ -48,6 +57,17 @@ final class VideoViewer { /// `emojiFor` maps a live Claude session to its sticky TitleEmoji so the /// chip wears the mark of the prompt that asked. func consumeRequests(emojiFor: (String) -> String = { _ in "" }) { + if FileManager.default.fileExists(atPath: Paths.videoCloseFile) { + try? FileManager.default.removeItem(atPath: Paths.videoCloseFile) + closeAll() + } + if FileManager.default.fileExists(atPath: Paths.videoTileFile) { + try? FileManager.default.removeItem(atPath: Paths.videoTileFile) + tile() + } + let before = controllers.count + defer { if controllers.count > before, controllers.count > 1 { tile() } } + let file = Paths.videoRequestFile if FileManager.default.fileExists(atPath: file) { let text = (try? String(contentsOfFile: file, encoding: .utf8)) ?? "" @@ -106,6 +126,30 @@ final class VideoViewer { func focus(_ id: String) { controllers[id]?.focus() } + /// Grid every open panel across the main screen's visible frame, each + /// aspect-fit inside its cell (same layout as ImageGroupPreview). + func tile() { + let ordered = controllers.values.sorted { + $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + let n = ordered.count + guard n > 0 else { return } + let screen = NSScreen.main?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let cols = Int(ceil(Double(n).squareRoot())) + let rows = Int(ceil(Double(n) / Double(cols))) + let gap: CGFloat = 16 + let cellW = (screen.width - gap * CGFloat(cols + 1)) / CGFloat(cols) + let cellH = (screen.height - gap * CGFloat(rows + 1)) / CGFloat(rows) + for (i, c) in ordered.enumerated() { + let col = i % cols + let row = i / cols + let x = screen.minX + gap + CGFloat(col) * (cellW + gap) + let yTop = screen.maxY - gap - CGFloat(row) * (cellH + gap) + c.fit(in: NSRect(x: x, y: yTop - cellH, width: cellW, height: cellH)) + } + } + func closeAll() { // close() triggers onClose which mutates the dictionary — iterate a copy. for controller in Array(controllers.values) { controller.close() } @@ -125,6 +169,11 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { private var reloadPending = false private var currentItemObservation: NSKeyValueObservation? private var currentIndex = 0 + /// Picture aspect (w/h) of the first movie, for grid fitting. + private let aspect: CGFloat + /// The queue beside the picture — only for playlists. + private let playlist: PlaylistList? + private static let playlistWidth: CGFloat = 220 var displayName: String { if sourcePaths.count == 1 { return (sourcePaths[0] as NSString).lastPathComponent } @@ -143,8 +192,13 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { self.onClose = onClose let asset = AVURLAsset(url: URL(fileURLWithPath: firstPath)) + let pictureSize = VideoWindowController.naturalSize(of: asset) + aspect = pictureSize.width / max(pictureSize.height, 1) + var frame = VideoWindowController.idealFrame(for: pictureSize) + if paths.count > 1 { frame.size.width += Self.playlistWidth } + playlist = paths.count > 1 ? PlaylistList(paths: paths) : nil panel = VideoPanel( - contentRect: VideoWindowController.idealFrame(for: asset), + contentRect: frame, styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) super.init() @@ -173,12 +227,26 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { playerView.translatesAutoresizingMaskIntoConstraints = false content.addSubview(playerView) - NSLayoutConstraint.activate([ + var constraints = [ playerView.topAnchor.constraint(equalTo: content.topAnchor), playerView.bottomAnchor.constraint(equalTo: content.bottomAnchor), playerView.leadingAnchor.constraint(equalTo: content.leadingAnchor), - playerView.trailingAnchor.constraint(equalTo: content.trailingAnchor), - ]) + ] + if let playlist { + playlist.translatesAutoresizingMaskIntoConstraints = false + playlist.onSelect = { [weak self] index in self?.playFrom(index: index) } + content.addSubview(playlist) + constraints += [ + playlist.topAnchor.constraint(equalTo: content.topAnchor), + playlist.bottomAnchor.constraint(equalTo: content.bottomAnchor), + playlist.trailingAnchor.constraint(equalTo: content.trailingAnchor), + playlist.widthAnchor.constraint(equalToConstant: Self.playlistWidth), + playerView.trailingAnchor.constraint(equalTo: playlist.leadingAnchor), + ] + } else { + constraints.append(playerView.trailingAnchor.constraint(equalTo: content.trailingAnchor)) + } + NSLayoutConstraint.activate(constraints) panel.onNext = { [weak self] in self?.next() } panel.onPrevious = { [weak self] in self?.previous() } currentItemObservation = player.observe(\.currentItem, options: [.new]) { [weak self] _, _ in @@ -291,6 +359,22 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { let position = sourcePaths.count > 1 ? " — \(currentIndex + 1)/\(sourcePaths.count)" : "" panel.title = (emoji.isEmpty ? "" : emoji + " ") + (currentPath as NSString).lastPathComponent + position + playlist?.highlight(index: currentIndex) + } + + /// Aspect-fit the panel inside a grid cell (the playlist column, when + /// present, rides along at its fixed width). + func fit(in cell: NSRect) { + let extra = playlist == nil ? 0 : Self.playlistWidth + var width = cell.width + var height = (width - extra) / max(aspect, 0.001) + if height > cell.height { + height = cell.height + width = height * aspect + extra + } + let frame = NSRect(x: cell.midX - width / 2, y: cell.midY - height / 2, + width: width, height: height) + panel.setFrame(frame, display: true, animate: false) } // MARK: live reload — render loops rewrite the mp4 in place @@ -344,16 +428,21 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { onClose() } - /// Size the window to the video's aspect, ~3/4 of the screen tall for - /// portrait, ~2/3 wide for landscape. - private static func idealFrame(for asset: AVURLAsset) -> NSRect { - let screen = NSScreen.main?.visibleFrame - ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + /// The movie's displayed pixel size (portrait default when unreadable). + private static func naturalSize(of asset: AVURLAsset) -> NSSize { var size = NSSize(width: 1080, height: 1920) if let track = asset.tracks(withMediaType: .video).first { let natural = track.naturalSize.applying(track.preferredTransform) size = NSSize(width: abs(natural.width), height: abs(natural.height)) } + return size + } + + /// Size the window to the video's aspect, ~3/4 of the screen tall for + /// portrait, ~2/3 wide for landscape. + private static func idealFrame(for size: NSSize) -> NSRect { + let screen = NSScreen.main?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let aspect = size.width / max(size.height, 1) var height = min(screen.height * 0.78, 980) var width = height * aspect @@ -366,6 +455,104 @@ private final class VideoWindowController: NSObject, NSWindowDelegate { } } +/// The queue beside a playlist's picture: one row per movie, the playing row +/// highlighted, a click jumps. Glass column so it reads as part of the panel. +private final class PlaylistList: NSView, NSTableViewDataSource, NSTableViewDelegate { + var onSelect: ((Int) -> Void)? + private let names: [String] + private let table = NSTableView() + private var syncing = false + + init(paths: [String]) { + names = paths.map { ($0 as NSString).lastPathComponent } + super.init(frame: .zero) + let glass = NSVisualEffectView() + glass.material = .hudWindow + glass.blendingMode = .behindWindow + glass.state = .active + glass.translatesAutoresizingMaskIntoConstraints = false + addSubview(glass) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + column.resizingMask = .autoresizingMask + table.addTableColumn(column) + table.headerView = nil + table.rowHeight = 26 + table.backgroundColor = .clear + table.selectionHighlightStyle = .regular + table.allowsEmptySelection = false + table.dataSource = self + table.delegate = self + table.target = self + table.action = #selector(rowClicked) + let scroll = NSScrollView() + scroll.documentView = table + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.translatesAutoresizingMaskIntoConstraints = false + addSubview(scroll) + + NSLayoutConstraint.activate([ + glass.topAnchor.constraint(equalTo: topAnchor), + glass.bottomAnchor.constraint(equalTo: bottomAnchor), + glass.leadingAnchor.constraint(equalTo: leadingAnchor), + glass.trailingAnchor.constraint(equalTo: trailingAnchor), + // Clear the hidden title bar's traffic lights. + scroll.topAnchor.constraint(equalTo: topAnchor, constant: 30), + scroll.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), + scroll.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + ]) + } + + required init?(coder: NSCoder) { nil } + + func highlight(index: Int) { + guard names.indices.contains(index) else { return } + syncing = true + table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) + table.scrollRowToVisible(index) + syncing = false + } + + @objc private func rowClicked() { + let row = table.clickedRow + guard row >= 0 else { return } + onSelect?(row) + } + + func numberOfRows(in tableView: NSTableView) -> Int { names.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let id = NSUserInterfaceItemIdentifier("row") + let cell = (tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView) ?? { + let cell = NSTableCellView() + cell.identifier = id + let label = NSTextField(labelWithString: "") + label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + label.lineBreakMode = .byTruncatingMiddle + label.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(label) + cell.textField = label + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 6), + label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -6), + label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + }() + cell.textField?.stringValue = "\(row + 1). \(names[row])" + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + // Keyboard selection in the list also jumps; programmatic sync doesn't. + guard !syncing, table.selectedRow >= 0 else { return } + onSelect?(table.selectedRow) + } +} + /// AVPlayerView eats right-clicks on its own transport controls, so setting /// `.menu` alone isn't enough to be sure the contextual menu appears. Pop it /// explicitly on right-mouse-down. @@ -406,6 +593,7 @@ extension AppDelegate { } @objc func closeAllVideos() { VideoViewer.shared.closeAll() } + @objc func tileAllVideos() { VideoViewer.shared.tile() } @objc func openVideoFromPanel() { NSApp.activate(ignoringOtherApps: true) diff --git a/toolchain/macos/blueberry-wallpaper/Sources/main.swift b/toolchain/macos/blueberry-wallpaper/Sources/main.swift index 10935db349..a1e3db858e 100644 --- a/toolchain/macos/blueberry-wallpaper/Sources/main.swift +++ b/toolchain/macos/blueberry-wallpaper/Sources/main.swift @@ -12,6 +12,49 @@ private struct PalsSpec { let reverse: Bool } +/// Optional emblem in place of the bundled Pals mesh, tinted and lit exactly +/// like it: a model file (obj, usdz, usdc, usda, dae, scn) drifts as geometry; +/// a PNG (alpha = the mark) drifts as a flat plane. Resolved from, in order, +/// `--emblem `, `$BLUEBERRY_WALLPAPER_EMBLEM`, then the first of +/// `~/.config/blueberry-wallpaper/emblem.{obj,usdz,usdc,usda,dae,scn,png}`; +/// `--emblem-scale ` (or `$BLUEBERRY_WALLPAPER_EMBLEM_SCALE`) sizes +/// the marks relative to the Pals field. Nothing else about the field changes. +private enum Emblem { + static let modelExtensions = ["obj", "usdz", "usdc", "usda", "dae", "scn"] + + static let path: String? = { + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--emblem"), i + 1 < args.count { return args[i + 1] } + if let env = ProcessInfo.processInfo.environment["BLUEBERRY_WALLPAPER_EMBLEM"], !env.isEmpty { + return env + } + let dir = NSString(string: "~/.config/blueberry-wallpaper").expandingTildeInPath + for ext in modelExtensions + ["png"] { + let candidate = "\(dir)/emblem.\(ext)" + if FileManager.default.fileExists(atPath: candidate) { return candidate } + } + return nil + }() + + static var isModel: Bool { + guard let path else { return false } + return modelExtensions.contains((path as NSString).pathExtension.lowercased()) + } + + static let scale: CGFloat = { + let args = CommandLine.arguments + var raw: String? + if let i = args.firstIndex(of: "--emblem-scale"), i + 1 < args.count { raw = args[i + 1] } + raw = raw ?? ProcessInfo.processInfo.environment["BLUEBERRY_WALLPAPER_EMBLEM_SCALE"] + guard let raw, let value = Double(raw), value > 0 else { return 1 } + return CGFloat(value) + }() + + static var renderer: String { + path == nil ? "live-glb" : (isModel ? "emblem-model" : "emblem-image") + } +} + private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { private struct Placement { let model: SCNNode @@ -81,9 +124,13 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { sceneView.autoresizingMask = [.width, .height] sceneView.scene = scene sceneView.delegate = self - sceneView.backgroundColor = .clear + // The backdrop is drawn by SceneKit itself (scene.background), so the + // view is opaque and mark edges resolve against the real backdrop + // instead of against a transparent surface composited later — the + // latter left tinted alpha fringes along every silhouette. + sceneView.backgroundColor = .black sceneView.antialiasingMode = .multisampling4X - sceneView.preferredFramesPerSecond = 30 + sceneView.preferredFramesPerSecond = 60 sceneView.rendersContinuously = true sceneView.isPlaying = true addSubview(sceneView) @@ -111,7 +158,10 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { super.viewDidMoveToWindow() updateAppearance(animated: false) guard let scale = window?.backingScaleFactor else { return } - sceneView.layer?.contentsScale = scale + // Render at no less than 2× and let the compositor downsample: on a + // 1× display 4× MSAA alone still shows stair-steps along the + // silhouettes, and supersampling on top of it smooths them out. + sceneView.layer?.contentsScale = max(scale, 2) } /// Posted after the system appearance flips. macOS keeps a light/dark @@ -154,7 +204,7 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { return NSColor(srgbRed: r / 65535, green: g / 65535, blue: b / 65535, alpha: 1) } - /// The live flat backdrop: slab's status tint when published, otherwise + /// The live backdrop tone: slab's status tint when published, otherwise /// the accent-tinted wash. private func backdropColor() -> NSColor { if let tint = statusTint { return tint } @@ -166,6 +216,32 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { return base.blended(withFraction: dark ? 0.12 : 0.13, of: accent) ?? base } + /// The backdrop as a soft vertical gradient around that tone. The top + /// keeps the mode's own direction — lightest up top in light mode, + /// darkest in dark mode — so the translucent menu bar sits on the tone + /// macOS expects; the field eases the other way toward the bottom. + private func backdropGradient() -> (top: NSColor, bottom: NSColor) { + let tone = backdropColor().usingColorSpace(.sRGB) ?? backdropColor() + let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + if dark { + return (tone.blended(withFraction: 0.22, of: .black) ?? tone, + tone.blended(withFraction: 0.14, of: .white) ?? tone) + } + return (tone.blended(withFraction: 0.16, of: .white) ?? tone, + tone.blended(withFraction: 0.12, of: .black) ?? tone) + } + + /// A tall, narrow gradient image; SceneKit stretches it over the view. + private static func gradientImage(top: NSColor, bottom: NSColor) -> NSImage { + let size = NSSize(width: 4, height: 512) + let image = NSImage(size: size) + image.lockFocus() + NSGradient(starting: bottom, ending: top)? + .draw(in: NSRect(origin: .zero, size: size), angle: 90) + image.unlockFocus() + return image + } + override func layout() { super.layout() background.frame = bounds @@ -189,21 +265,35 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { } private func loadModel() { - guard let url = Bundle.main.url( - forResource: "pals-mesh", withExtension: "usdc", - subdirectory: "PalsModel"), - let imported = try? SCNScene(url: url, options: nil) - else { - fputs("could not load bundled Pals GLB geometry\n", stderr) - return - } - - let prototype = SCNNode() - for child in imported.rootNode.childNodes { - child.removeFromParentNode() - prototype.addChildNode(child) + let prototype: SCNNode + if let emblemPath = Emblem.path, !Emblem.isModel { + guard let emblem = emblemPrototype(path: emblemPath) else { + fputs("could not load emblem image at \(emblemPath)\n", stderr) + return + } + prototype = emblem + } else { + let url: URL? + if let emblemPath = Emblem.path { + url = URL(fileURLWithPath: emblemPath) + } else { + url = Bundle.main.url(forResource: "pals-mesh", withExtension: "usdc", + subdirectory: "PalsModel") + } + guard let url, let imported = try? SCNScene(url: url, options: nil) else { + fputs("could not load geometry at \(url?.path ?? "bundled Pals GLB")\n", stderr) + return + } + prototype = SCNNode() + for child in imported.rootNode.childNodes { + child.removeFromParentNode() + prototype.addChildNode(child) + } + applyOriginalGLBMaterials(to: prototype) + if Emblem.isModel, Emblem.scale != 1 { + prototype.scale = SCNVector3(Emblem.scale, Emblem.scale, Emblem.scale) + } } - applyOriginalGLBMaterials(to: prototype) let (lo, hi) = prototype.boundingBox modelWidth = max(CGFloat(hi.x - lo.x), 0.001) modelHeight = max(CGFloat(hi.y - lo.y), 0.001) @@ -266,6 +356,32 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { } } + /// A flat, double-sided plane carrying the emblem's alpha as its mask. + /// Unlit, so it reads as one flat tone — the same accent/backdrop blend + /// the mesh wears — and turns like the mesh does. + private func emblemPrototype(path: String) -> SCNNode? { + guard let image = NSImage(contentsOfFile: path), image.size.height > 0 else { return nil } + let aspect = image.size.width / image.size.height + // The Pals mesh spans 1.9 × 1.24 model units before `markScale`; a + // 1.6-unit-tall emblem covers about the same footprint, so the specs' + // sizes carry over unchanged. + let height: CGFloat = 1.6 * Emblem.scale + let plane = SCNPlane(width: height * aspect, height: height) + let material = SCNMaterial() + material.lightingModel = .constant + material.diffuse.contents = NSColor.controlAccentColor + material.transparent.contents = image + material.transparencyMode = .aOne + material.isDoubleSided = true + material.writesToDepthBuffer = false + plane.materials = [material] + materials.append(material) + let node = SCNNode() + node.addChildNode(SCNNode(geometry: plane)) + applyAccentColor(animated: false) + return node + } + private func applyOriginalGLBMaterials(to model: SCNNode) { model.enumerateChildNodes { node, _ in for material in node.geometry?.materials ?? [] { @@ -329,12 +445,13 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { private func updateAppearance(animated: Bool) { applyAccentColor(animated: animated) - let color = backdropColor() + let gradient = backdropGradient() + scene.background.contents = Self.gradientImage(top: gradient.top, bottom: gradient.bottom) // macOS uses the opaque desktop window's backing color when choosing - // menu-bar contrast. Keep it in sync with the visible layer, including - // appearance flips after launch; a stale light backing creates a pale - // top scrim with black menu text over an otherwise dark desktop. - window?.backgroundColor = color + // menu-bar contrast. Keep it on the top tone, including appearance + // flips after launch; a stale light backing creates a pale top scrim + // with black menu text over an otherwise dark desktop. + window?.backgroundColor = gradient.top let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua let flipped = lastDark != nil && lastDark != dark lastDark = dark @@ -346,7 +463,7 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { } CATransaction.begin() CATransaction.setAnimationDuration(animated ? 0.65 : 0) - background.backgroundColor = color.cgColor + background.backgroundColor = gradient.top.cgColor CATransaction.commit() } } @@ -412,7 +529,7 @@ private final class WallpaperDelegate: NSObject, NSApplicationDelegate { let pixelsWide = Int(screen.frame.width * screen.backingScaleFactor) let pixelsHigh = Int(screen.frame.height * screen.backingScaleFactor) print("screen=\(pixelsWide)x\(pixelsHigh) scale=\(screen.backingScaleFactor) " + - "window=\(window.windowNumber) level=\(window.level.rawValue) renderer=live-glb") + "window=\(window.windowNumber) level=\(window.level.rawValue) renderer=\(Emblem.renderer)") fflush(stdout) windows.append(window) views.append(view)