Files
Snaggle/GreenshotMac/Core/Appearance.swift
T
2026-09-17 16:15:02 -05:00

172 lines
6.8 KiB
Swift

// 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")
}