diff --git a/slab/bin/slab-images b/slab/bin/slab-images new file mode 100644 --- /dev/null +++ b/slab/bin/slab-images @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# slab-images [b.png …] — open a GROUP of images as a tiled wall of +# chromeless glass panels (Preview-free review). Writes one absolute path per +# line to $SLAB_HOME/state/open-images; the menubar's 2 s tick consumes the +# whole file as ONE group, grid-tiles it across the main screen, and replaces +# any previous group. Esc/⌘W dismiss a panel. See +# slab/menubar-swift/Sources/SlabMenubar/ImageGroupPreview.swift. +set -euo pipefail + +SLAB_HOME="${SLAB_HOME:-$HOME/.local/share/slab}" +REQUEST="$SLAB_HOME/state/open-images" + +if [ $# -eq 0 ]; then + echo "usage: slab-images [more …]" >&2 + exit 1 +fi + +mkdir -p "$SLAB_HOME/state" + +# Truncate: each invocation is one fresh group (the tick reads the whole file). +: > "$REQUEST" +for f in "$@"; do + if [ ! -f "$f" ]; then + echo "slab-images: no such file: $f" >&2 + exit 1 + fi + abs="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" + printf '%s\n' "$abs" >> "$REQUEST" +done +echo "slab-images: queued $# image(s) → $REQUEST" diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -929,6 +929,9 @@ gamepadRow.addArrangedSubview(gamepadLabelStack) gamepadRow.addArrangedSubview(gamepadSpacer) gamepadRow.addArrangedSubview(gamepadSchemePopUp) + // Tagged so the App Store screenshot capture (PopoverCapture.swift) + // can hide this row — gamepad config is noise in a marketing shot. + gamepadRow.identifier = NSUserInterfaceItemIdentifier("mb.gamepadRow") stack.addArrangedSubview(gamepadRow) gamepadRow.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -16).isActive = true diff --git a/slab/menuband/Sources/MenuBand/PopoverCapture.swift b/slab/menuband/Sources/MenuBand/PopoverCapture.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/PopoverCapture.swift @@ -0,0 +1,139 @@ +import AppKit + +// PopoverCapture — headless capture of the REAL popover interface (the actual +// MenuBandPopoverViewController view tree: instrument cluster, GM grid, QWERTY +// map, mode picker, mix slider, octave stepper) so App Store screenshots use +// the shipping UI, not a mock. Mirrors AboutCLI.runIfRequested. +// +// MenuBand --render-popover --out popover.png [--scale 3] [--dark] [--program N] +// +// Unlike AboutCapture (a self-contained window controller), the popover VC +// needs a live MenuBandController — it reads instrument/mode/octave state from +// it. We construct one but never call bootstrap(), so the audio engine / MIDI +// never start; the view tree lays out and paints from the controller's default +// state. The Metal mini-waveform strip may come back empty under cacheDisplay +// (Metal layers don't always honor it) — that's an acceptable dark strip; the +// instrument surface is the point. +enum PopoverCLI { + static func runIfRequested(_ args: [String]) -> Bool { + guard args.contains("--render-popover") else { return false } + func val(_ f: String) -> String? { + guard let i = args.firstIndex(of: f), i + 1 < args.count else { return nil } + return args[i + 1] + } + let out = val("--out") ?? "/tmp/popover.png" + let scale = max(1.0, Double(val("--scale") ?? "3") ?? 3) + + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + app.appearance = NSAppearance(named: args.contains("--dark") ? .darkAqua : .aqua) + + // Load with the instrument chart EXPANDED so the shot shows the full + // GM grid + QWERTY map, not just the collapsed readout. This writes to + // the CLI binary's own defaults domain (process-name based), not the + // installed app's (computer.aestheticcomputer.menuband), so it doesn't + // disturb the user's real chart state. + if !args.contains("--collapsed") { + UserDefaults.standard.set(true, forKey: "MBInstrumentChartExpanded") + } + + let controller = MenuBandController() + // Pick a melodic GM program so the readout names a recognizable + // instrument (defaults to 0 = Acoustic Grand Piano) instead of the + // mic "Sample Voice" the cold controller starts on. + let prog = UInt8(val("--program") ?? "0") ?? 0 + controller.setMelodicProgram(prog) + + let vc = MenuBandPopoverViewController() + vc.menuBand = controller + // Accessing `.view` triggers loadView (the macOS 11-safe way; there is + // no loadViewIfNeeded before macOS 14). Then pull instrument/mode/ + // octave state across so the readout, grid selection, and mode buttons + // paint their real values rather than blank defaults. + let v = vc.view + vc.syncFromController() + vc.refreshInstrumentVisuals() + // Hide the gamepad config row — it's noise in a marketing shot. Tagged + // with identifier "mb.gamepadRow" in MenuBandPopoverViewController; + // hiding an NSStackView arranged subview collapses its space. + if !args.contains("--keep-gamepad") { + func hideTagged(_ view: NSView) { + if view.identifier?.rawValue == "mb.gamepadRow" { view.isHidden = true; return } + view.subviews.forEach(hideTagged) + } + hideTagged(v) + } + v.layoutSubtreeIfNeeded() + + var size = v.fittingSize + if size.width < 100 || size.height < 100 { size = NSSize(width: 360, height: 560) } + v.frame = NSRect(origin: .zero, size: size) + v.layoutSubtreeIfNeeded() + v.displayIfNeeded() + + let bounds = v.bounds + let pw = Int((bounds.width * scale).rounded()) + let ph = Int((bounds.height * scale).rounded()) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pw, pixelsHigh: ph, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { + FileHandle.standardError.write(Data("✗ bitmap alloc failed\n".utf8)); return true + } + rep.size = bounds.size // points; pixels are scale× → Retina capture + v.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write(Data("✗ png encode failed\n".utf8)); return true + } + try? png.write(to: URL(fileURLWithPath: out)) + print("popover \(pw)x\(ph) (native \(Int(bounds.width))x\(Int(bounds.height))) → \(out)") + return true + } +} + +// JamCapture — headless capture of the REAL "Looking For Players?" (Jam) +// window: the AC badge + computer-club invite (looking-for-players.png). +// Mirrors AboutCLI. Usage: MenuBand --render-jam --out jam.png [--scale 3] [--dark] +enum JamCLI { + static func runIfRequested(_ args: [String]) -> Bool { + guard args.contains("--render-jam") else { return false } + func val(_ f: String) -> String? { + guard let i = args.firstIndex(of: f), i + 1 < args.count else { return nil } + return args[i + 1] + } + let out = val("--out") ?? "/tmp/jam.png" + let scale = max(1.0, Double(val("--scale") ?? "3") ?? 3) + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + app.appearance = NSAppearance(named: args.contains("--dark") ? .darkAqua : .aqua) + if let lang = val("--lang") { Localization.current = lang } + + let ctrl = JamWindowController() + guard let win = ctrl.window, let cv = win.contentView else { + FileHandle.standardError.write(Data("✗ no jam content view\n".utf8)); return true + } + cv.layoutSubtreeIfNeeded() + let fit = cv.fittingSize + win.setContentSize(NSSize(width: max(280, fit.width), height: max(200, fit.height))) + cv.layoutSubtreeIfNeeded() + win.displayIfNeeded() + + let bounds = cv.bounds + let pw = Int((bounds.width * scale).rounded()) + let ph = Int((bounds.height * scale).rounded()) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pw, pixelsHigh: ph, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { + FileHandle.standardError.write(Data("✗ bitmap alloc failed\n".utf8)); return true + } + rep.size = bounds.size + cv.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write(Data("✗ png encode failed\n".utf8)); return true + } + try? png.write(to: URL(fileURLWithPath: out)) + print("jam \(pw)x\(ph) (native \(Int(bounds.width))x\(Int(bounds.height))) → \(out)") + return true + } +} diff --git a/slab/menuband/Sources/MenuBand/main.swift b/slab/menuband/Sources/MenuBand/main.swift --- a/slab/menuband/Sources/MenuBand/main.swift +++ b/slab/menuband/Sources/MenuBand/main.swift @@ -17,6 +17,16 @@ if AboutCLI.runIfRequested(CommandLine.arguments) { exit(0) } +// Headless capture of the real popover interface for App Store screenshots. +if PopoverCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + +// Headless capture of the real "Looking For Players?" Jam window. +if JamCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + // Singleton guard: when MenuBand is spawned by both launchd's // KeepAlive (after crash / sleep wake) AND MenuBandLauncher's // double-tap path at the same time, we get two instances fighting diff --git a/slab/menuband/bin/app-store-real.mjs b/slab/menuband/bin/app-store-real.mjs new file mode 100644 --- /dev/null +++ b/slab/menuband/bin/app-store-real.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// app-store-real.mjs — Mac App Store screenshots from the REAL Menu Band UI. +// +// Drives the app's own headless render modes — `--render-popover`, +// `--render-about`, `--render-jam`, `--render-menubar` (PopoverCapture/ +// AboutCapture/JamCapture/MenubarCapture.swift snapshot the actual AppKit view +// trees via cacheDisplay) — then plainly places those true-UI PNGs on a SOLID +// purple desktop with a minimal menu bar (Apple logo in the corner + the real +// piano status item + clock). No marketing typography. +// +// Four shots: menu bar (no popover) / menu bar + popover / About / Jam. +// Output: ~/Desktop/MenuBand-AppStore-Real/*.png (review) AND +// fastlane/screenshots/en-US/*.png (so `fastlane mac shots` uploads). + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const BIN = resolve(ROOT, ".build/debug/MenuBand"); +const DESK = resolve(homedir(), "Desktop/MenuBand-AppStore-Real"); +const RAW = resolve(DESK, "raw"); +const FL = resolve(ROOT, "fastlane/screenshots/en-US"); +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const W = 2880, H = 1800; +const PURPLE = "#6d24c4"; // solid desktop +const BAR_H = 76; +const S = 56 / 22; // shared screen scale: menubar status item 22pt -> 56px + +mkdirSync(RAW, { recursive: true }); +mkdirSync(FL, { recursive: true }); +if (!existsSync(BIN)) { console.error(`build first: (cd ${ROOT} && swift build)`); process.exit(1); } + +// ── 1. render the real UI surfaces ────────────────────────────────────────── +const render = (args, out) => { + execFileSync(BIN, [...args, "--out", resolve(RAW, out), "--scale", "3"], { stdio: "ignore" }); + return out; +}; +console.log("rendering real UI surfaces…"); +render(["--render-menubar", "--notes", "60,64,67"], "menubar.png"); +render(["--render-popover", "--program", "0"], "popover.png"); +render(["--render-about"], "about.png"); +render(["--render-jam"], "jam.png"); + +const uri = (f) => `data:image/png;base64,${readFileSync(resolve(RAW, f)).toString("base64")}`; +// native point heights of each surface (from the Swift renders), for shared scale +const NATIVE_H = { popover: 456, about: 487, jam: 360 }; + +// ── 2. the four shots: plainly place the real screens on a purple desktop ──── +// `center` floats the window centered below the bar; `drop` anchors it under +// the top-right status item like the live popover. +const SHOTS = [ + { file: "01-menu-bar", screen: null }, + { file: "02-menu-bar-popover", screen: "popover", place: "drop" }, + { file: "03-about", screen: "about", place: "center" }, + { file: "04-looking-for-players", screen: "jam", place: "center" }, +]; + +const FILL = "#f3f3f5"; // solid light-theme backing behind the panels +const screenCSS = (s) => { + if (!s.screen) return ""; + const h = Math.round(NATIVE_H[s.screen] * S); + const img = uri(`${s.screen}.png`); + const pos = s.place === "drop" + ? `top:${BAR_H - 8}px; right:320px;` + : `top:${BAR_H + Math.round((H - BAR_H - h) / 2)}px; left:50%; transform:translateX(-50%);`; + // The captured panels have translucent (vibrancy) backgrounds. Fill them with + // a solid light-theme color so the purple desktop doesn't bleed through — + // the img composites over this element background; border-radius clips both. + return ``; +}; + +const html = (s) => ` + +${screenCSS(s)}`; + +console.log("compositing 2880x1800 canvases (solid purple)…"); +for (const s of SHOTS) { + const htmlPath = resolve(RAW, `.${s.file}.html`); + writeFileSync(htmlPath, html(s)); + const deskOut = resolve(DESK, `${s.file}.png`); + execFileSync(CHROME, ["--headless", "--disable-gpu", "--hide-scrollbars", + "--force-device-scale-factor=1", `--window-size=${W},${H}`, + `--screenshot=${deskOut}`, `file://${htmlPath}`], { stdio: "ignore" }); + const flOut = resolve(FL, `${s.file}-2880x1800.png`); + execFileSync("magick", [deskOut, "-background", PURPLE, "-alpha", "remove", "-alpha", "off", + "-resize", `${W}x${H}!`, flOut], { stdio: "ignore" }); + console.log(` ${s.file}.png`); +} + +// keep only the current real set in fastlane +for (const f of readdirSync(FL)) { + if (!SHOTS.some((s) => f.startsWith(s.file))) { rmSync(resolve(FL, f)); console.log(` removed stale ${f}`); } +} +console.log(`\n✓ review on Desktop: ${DESK}`); +console.log(`✓ staged for upload: ${FL}`); diff --git a/slab/menuband/fastlane/screenshots/en-US/01-menu-bar-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/01-menu-bar-2880x1800.png new file mode 100644 --- /dev/null +++ b/slab/menuband/fastlane/screenshots/en-US/01-menu-bar-2880x1800.png diff --git a/slab/menuband/fastlane/screenshots/en-US/01-menubar-instrument-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/01-menubar-instrument-2880x1800.png deleted file mode 100644 --- a/slab/menuband/fastlane/screenshots/en-US/01-menubar-instrument-2880x1800.png +++ /dev/null diff --git a/slab/menuband/fastlane/screenshots/en-US/02-menu-bar-popover-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/02-menu-bar-popover-2880x1800.png new file mode 100644 --- /dev/null +++ b/slab/menuband/fastlane/screenshots/en-US/02-menu-bar-popover-2880x1800.png diff --git a/slab/menuband/fastlane/screenshots/en-US/02-type-to-play-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/02-type-to-play-2880x1800.png deleted file mode 100644 --- a/slab/menuband/fastlane/screenshots/en-US/02-type-to-play-2880x1800.png +++ /dev/null diff --git a/slab/menuband/fastlane/screenshots/en-US/03-about-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/03-about-2880x1800.png new file mode 100644 --- /dev/null +++ b/slab/menuband/fastlane/screenshots/en-US/03-about-2880x1800.png diff --git a/slab/menuband/fastlane/screenshots/en-US/03-send-midi-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/03-send-midi-2880x1800.png deleted file mode 100644 --- a/slab/menuband/fastlane/screenshots/en-US/03-send-midi-2880x1800.png +++ /dev/null diff --git a/slab/menuband/fastlane/screenshots/en-US/04-instrument-palette-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/04-instrument-palette-2880x1800.png deleted file mode 100644 --- a/slab/menuband/fastlane/screenshots/en-US/04-instrument-palette-2880x1800.png +++ /dev/null diff --git a/slab/menuband/fastlane/screenshots/en-US/04-looking-for-players-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/04-looking-for-players-2880x1800.png new file mode 100644 --- /dev/null +++ b/slab/menuband/fastlane/screenshots/en-US/04-looking-for-players-2880x1800.png diff --git a/slab/menuband/fastlane/screenshots/en-US/05-live-feedback-2880x1800.png b/slab/menuband/fastlane/screenshots/en-US/05-live-feedback-2880x1800.png deleted file mode 100644 --- a/slab/menuband/fastlane/screenshots/en-US/05-live-feedback-2880x1800.png +++ /dev/null diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -272,6 +272,10 @@ // Pulled `frame` screenshots open in FramePreview, badged with // the source machine name (see FramePreview.swift / `frame // --preview`). Machine identity leads, so no session emoji here. FramePreview.shared.consumeRequests() + // Groups of images (e.g. App Store screenshots) open as a tiled + // wall of chromeless glass panels — Preview-free review. See + // ImageGroupPreview.swift / `slab-images`. + ImageGroupPreview.shared.consumeRequests() self.applyTerminalDecor() self.applyDesktopTint() } diff --git a/slab/menubar-swift/Sources/SlabMenubar/ImageGroupPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/ImageGroupPreview.swift new file mode 100644 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ImageGroupPreview.swift @@ -0,0 +1,203 @@ +// ImageGroupPreview — open a GROUP of images as a wall of chromeless glass +// panels, grid-tiled, so reviewing a set (e.g. App Store screenshots) is a +// glance with no Preview.app chrome and no Finder dig. The FramePreview +// contract, generalized from "one panel per fleet machine" to "N panels per +// requested group", with an AXTiler-style grid laid over the main screen. +// +// How it gets asked: a CLI writes one absolute image path per line to +// $SLAB_HOME/state/open-images, then the menubar's 2 s tick consumes the file +// (tiny read, no shell-outs — per slab-menubar-perf) and opens the whole batch +// as ONE group, replacing any previous group. Each panel is Esc/⌘W dismissable +// and click-drag relocatable; "Close All Images" drops the lot. +// +// Tiling: cols = ceil(sqrt(n)), rows = ceil(n/cols); each panel gets a cell of +// the main screen's visibleFrame and aspect-fits its image inside. This is the +// same direct-AX placement idea as AXTiler.tileNow(), but applied to our OWN +// windows (no AX round-trip needed — we set the panel frames in process). +import AppKit + +extension Paths { + /// One absolute image path per line; the whole file is one group, consumed + /// each tick and shown as a tiled wall. + static var imageGroupRequestFile: String { "\(slabHome)/state/open-images" } +} + +final class ImageGroupPreview { + static let shared = ImageGroupPreview() + private var controllers: [ImagePanelController] = [] + + var isShowing: Bool { !controllers.isEmpty } + + /// Called from the main-thread side of AppDelegate.refresh() every tick. + func consumeRequests() { + let file = Paths.imageGroupRequestFile + guard FileManager.default.fileExists(atPath: file) else { return } + let text = (try? String(contentsOfFile: file, encoding: .utf8)) ?? "" + try? FileManager.default.removeItem(atPath: file) + let paths = text.split(separator: "\n") + .map { ($0.trimmingCharacters(in: .whitespaces) as NSString).expandingTildeInPath } + .filter { !$0.isEmpty && FileManager.default.fileExists(atPath: $0) } + guard !paths.isEmpty else { return } + openGroup(paths) + } + + /// A new group REPLACES the previous one — re-running the CLI gives a clean + /// wall of the latest set rather than stacking old panels behind new. + func openGroup(_ paths: [String]) { + closeAll() + for (i, p) in paths.enumerated() { + guard let c = ImagePanelController(path: p, onClose: { [weak self] ctrl in + self?.controllers.removeAll { $0 === ctrl } + }) else { continue } + c.indexBadge = "\(i + 1)/\(paths.count)" + controllers.append(c) + } + layoutGrid() + // Raise the whole wall; key the first so Esc has a target. + NSApp.activate(ignoringOtherApps: true) + for c in controllers { c.orderFront() } + controllers.first?.focus() + } + + /// Grid the open panels across the main screen's visible frame. + private func layoutGrid() { + let n = controllers.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 controllers.enumerated() { + let col = i % cols + let row = i / cols + // Top-to-bottom rows: AppKit y grows upward, so row 0 is the top. + 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() { + for c in Array(controllers) { c.close() } + controllers.removeAll() + } +} + +/// One chromeless panel for one image. Aspect-fits its image inside an assigned +/// grid cell; any click-drag moves the window; Esc/⌘W dismiss. +private final class ImagePanelController: NSObject, NSWindowDelegate { + private let path: String + private let panel: ImagePanel + private let imageView = DraggableImageView() + private let onClose: (ImagePanelController) -> Void + private let aspect: CGFloat + var indexBadge: String = "" { didSet { badge.stringValue = indexBadge } } + private let badge = NSTextField(labelWithString: "") + + init?(path: String, onClose: @escaping (ImagePanelController) -> Void) { + guard let image = NSImage(contentsOfFile: path), image.size.width > 0 else { return nil } + self.path = path + self.onClose = onClose + self.aspect = image.size.width / max(image.size.height, 1) + panel = ImagePanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + super.init() + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.isRestorable = false + panel.delegate = self + panel.title = (path as NSString).lastPathComponent + panel.isOpaque = false + panel.backgroundColor = .black + panel.minSize = NSSize(width: 200, height: 140) + if image.size.width > 0 && image.size.height > 0 { panel.contentAspectRatio = image.size } + + let content = panel.contentView! + imageView.image = image + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.imageAlignment = .alignCenter + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.setContentHuggingPriority(.defaultLow, for: .horizontal) + imageView.setContentHuggingPriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + content.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: content.topAnchor), + imageView.bottomAnchor.constraint(equalTo: content.bottomAnchor), + imageView.leadingAnchor.constraint(equalTo: content.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: content.trailingAnchor), + ]) + installBadge() + } + + /// HUD chip naming the file + index, matching FramePreview/PdfViewer style. + private func installBadge() { + let chip = NSVisualEffectView() + chip.material = .hudWindow + chip.blendingMode = .withinWindow + chip.state = .active + chip.wantsLayer = true + chip.layer?.cornerRadius = 8 + chip.translatesAutoresizingMaskIntoConstraints = false + badge.stringValue = (path as NSString).lastPathComponent + badge.font = .monospacedSystemFont(ofSize: 11, weight: .semibold) + badge.textColor = .labelColor + badge.lineBreakMode = .byTruncatingMiddle + badge.translatesAutoresizingMaskIntoConstraints = false + badge.toolTip = path + chip.addSubview(badge) + let content = panel.contentView! + content.addSubview(chip) + NSLayoutConstraint.activate([ + chip.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), + chip.topAnchor.constraint(equalTo: content.topAnchor, constant: 10), + badge.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 10), + badge.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -10), + badge.centerYAnchor.constraint(equalTo: chip.centerYAnchor), + badge.widthAnchor.constraint(lessThanOrEqualToConstant: 260), + chip.heightAnchor.constraint(equalToConstant: 24), + ]) + } + + /// Place the panel inside `cell`, aspect-fit to the image (so the panel is + /// no bigger than the image needs and stays centered in its grid cell). + func fit(in cell: NSRect) { + var w = cell.width + var h = w / max(aspect, 0.001) + if h > cell.height { h = cell.height; w = h * aspect } + let x = cell.minX + (cell.width - w) / 2 + let y = cell.minY + (cell.height - h) / 2 + panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true) + } + + func orderFront() { panel.orderFrontRegardless() } + func focus() { panel.makeKeyAndOrderFront(nil) } + func close() { panel.close() } + func windowWillClose(_ notification: Notification) { onClose(self) } +} + +private final class DraggableImageView: NSImageView { + override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) } +} + +private final class ImagePanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + override func cancelOperation(_ sender: Any?) { close() } +} + +// MARK: - slab manages the viewer +extension AppDelegate { + @objc func closeAllImages() { ImageGroupPreview.shared.closeAll() } +}