second commit
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -110,7 +110,8 @@ final class Configuration {
|
||||
Keys.editorHighlightColor: "#FFFF00",
|
||||
Keys.editorDropShadow: false,
|
||||
Keys.showMagnifier: true,
|
||||
Keys.showSelectionSize: true
|
||||
Keys.showSelectionSize: true,
|
||||
Keys.appearanceMode: AppearanceMode.system.rawValue
|
||||
])
|
||||
}
|
||||
|
||||
@@ -138,6 +139,7 @@ final class Configuration {
|
||||
static let editorDropShadow = "EditorDropShadow"
|
||||
static let showMagnifier = "ShowMagnifier"
|
||||
static let showSelectionSize = "ShowSelectionSize"
|
||||
static let appearanceMode = "AppearanceMode"
|
||||
static let hotkeyRegion = "HotkeyRegion"
|
||||
static let hotkeyFullscreen = "HotkeyFullscreen"
|
||||
static let hotkeyWindow = "HotkeyWindow"
|
||||
@@ -208,6 +210,11 @@ final class Configuration {
|
||||
set { defaults.set(newValue, forKey: Keys.showSelectionSize) }
|
||||
}
|
||||
|
||||
var appearanceMode: AppearanceMode {
|
||||
get { AppearanceMode(rawValue: defaults.string(forKey: Keys.appearanceMode) ?? "system") ?? .system }
|
||||
set { defaults.set(newValue.rawValue, forKey: Keys.appearanceMode) }
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
var defaultDestination: DestinationID {
|
||||
|
||||
@@ -76,6 +76,38 @@ enum ImageExporter {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Reads a single pixel from an image. `point` is in image points with a top-left
|
||||
/// origin; `scale` converts those points to pixels.
|
||||
static func color(at point: CGPoint, in image: CGImage, scale: CGFloat) -> NSColor? {
|
||||
let x = Int((point.x * scale).rounded(.down))
|
||||
let y = Int((point.y * scale).rounded(.down))
|
||||
guard x >= 0, y >= 0, x < image.width, y < image.height else { return nil }
|
||||
|
||||
var pixel: [UInt8] = [0, 0, 0, 0]
|
||||
guard let context = CGContext(data: &pixel,
|
||||
width: 1,
|
||||
height: 1,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 4,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else { return nil }
|
||||
|
||||
// Draw the image shifted so the pixel of interest lands in the 1x1 context.
|
||||
context.draw(image,
|
||||
in: CGRect(x: -CGFloat(x),
|
||||
y: -CGFloat(image.height - 1 - y),
|
||||
width: CGFloat(image.width),
|
||||
height: CGFloat(image.height)))
|
||||
|
||||
let alpha = CGFloat(pixel[3]) / 255
|
||||
guard alpha > 0 else { return NSColor.clear }
|
||||
return NSColor(srgbRed: CGFloat(pixel[0]) / 255 / alpha,
|
||||
green: CGFloat(pixel[1]) / 255 / alpha,
|
||||
blue: CGFloat(pixel[2]) / 255 / alpha,
|
||||
alpha: 1)
|
||||
}
|
||||
|
||||
static func format(forPathExtension pathExtension: String) -> OutputFormat {
|
||||
switch pathExtension.lowercased() {
|
||||
case "jpg", "jpeg": return .jpg
|
||||
|
||||
Reference in New Issue
Block a user