172 lines
6.8 KiB
Swift
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")
|
|
}
|