// Appearance.swift // GreenshotMac // // Appearance handling and the shared material/shape vocabulary for the UI. // Everything here follows the system appearance by default; the user can override it. import AppKit enum AppearanceMode: String, CaseIterable { case system case light case dark var title: String { switch self { case .system: return "Match system" case .light: return "Light" case .dark: return "Dark" } } var nsAppearance: NSAppearance? { switch self { case .system: return nil // nil = inherit from macOS case .light: return NSAppearance(named: .aqua) case .dark: return NSAppearance(named: .darkAqua) } } } @MainActor enum Theme { /// Applies the user's appearance choice to the whole app. static func apply() { NSApp.appearance = Configuration.shared.appearanceMode.nsAppearance } // MARK: - Shapes /// Corner radius used for panels and preview wells. /// macOS 27 moved back towards squarer, more consistent radii than Tahoe used. /// /// These are `nonisolated` because default argument expressions are evaluated /// outside the main actor, and `panelRadius` is used as one below. nonisolated static let panelRadius: CGFloat = 10 /// Corner radius used for buttons and small chips. nonisolated static let controlRadius: CGFloat = 6 // MARK: - Materials /// A translucent backdrop that picks up whatever is behind the window and adapts /// to light/dark automatically. Used for toolbars, style bars and dialog chrome. /// /// On macOS 26 the system materials render with the Liquid Glass treatment; on earlier /// systems they fall back to the classic vibrancy blur. Either way the colours come from /// the system rather than being hard coded, so an appearance override is respected. static func backdrop(material: NSVisualEffectView.Material = .headerView, blending: NSVisualEffectView.BlendingMode = .withinWindow, radius: CGFloat = 0) -> NSVisualEffectView { let view = NSVisualEffectView() view.material = material view.blendingMode = blending view.state = .followsWindowActiveState view.translatesAutoresizingMaskIntoConstraints = false if radius > 0 { view.wantsLayer = true view.layer?.cornerRadius = radius view.layer?.cornerCurve = .continuous view.layer?.masksToBounds = true } return view } /// Fills a container with a child view, inset by `inset` on every edge. static func fill(_ child: NSView, in container: NSView, inset: CGFloat = 0) { child.translatesAutoresizingMaskIntoConstraints = false container.addSubview(child) NSLayoutConstraint.activate([ child.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: inset), child.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -inset), child.topAnchor.constraint(equalTo: container.topAnchor, constant: inset), child.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -inset) ]) } /// A hairline divider that reads correctly in both appearances. /// /// `axis` is the direction the line runs. A separator NSBox has no intrinsic size /// across its thin edge, so that dimension must be pinned or Auto Layout is free to /// stretch the divider over the content next to it. static func divider(axis: NSUserInterfaceLayoutOrientation = .horizontal) -> NSBox { let box = NSBox() box.boxType = .separator box.translatesAutoresizingMaskIntoConstraints = false switch axis { case .horizontal: box.heightAnchor.constraint(equalToConstant: 1).isActive = true case .vertical: box.widthAnchor.constraint(equalToConstant: 1).isActive = true @unknown default: break } return box } /// True when the person has dialled transparency down, either with the macOS 27 /// transparency slider or the Reduce Transparency accessibility setting. static var prefersOpaqueSurfaces: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency } /// Wraps `content` in a Liquid Glass panel on macOS 26 and later, falling back to a /// vibrancy backdrop on older systems. Used for floating surfaces such as the /// destination picker's preview well — not for toolbars, which macOS 27 wants to read /// as solid bars rather than floating glass. static func panel(_ content: NSView, radius: CGFloat = panelRadius, material: NSVisualEffectView.Material = .underPageBackground, inset: CGFloat = 0) -> NSView { if #available(macOS 26.0, *), !prefersOpaqueSurfaces { let glass = NSGlassEffectView() glass.translatesAutoresizingMaskIntoConstraints = false glass.cornerRadius = radius content.translatesAutoresizingMaskIntoConstraints = false glass.contentView = content return glass } let view = backdrop(material: material, radius: radius) fill(content, in: view, inset: inset) return view } /// The bezel style for a chrome button: Liquid Glass where the system has it. static func styleButton(_ button: NSButton) { if #available(macOS 26.0, *) { button.bezelStyle = .glass } else { button.bezelStyle = .texturedRounded } } /// Groups nearby glass elements so the system can merge them into one shape /// instead of rendering each separately. static func glassGroup(_ views: [NSView]) -> NSView? { guard #available(macOS 26.0, *), !prefersOpaqueSurfaces else { return nil } let container = NSGlassEffectContainerView() container.translatesAutoresizingMaskIntoConstraints = false let stack = NSStackView(views: views) stack.orientation = .horizontal stack.spacing = 4 container.contentView = stack return container } // MARK: - Window chrome /// Gives a window the unified, translucent titlebar used throughout the app. static func styleWindowChrome(_ window: NSWindow, fullSizeContent: Bool = false) { window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true if fullSizeContent { window.styleMask.insert(.fullSizeContentView) } } } extension Notification.Name { /// Posted when the appearance override changes, so open windows can re-apply it. static let appearanceChanged = Notification.Name("GreenshotMacAppearanceChanged") }