second commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 16:15:02 -05:00
parent 87fe7927c4
commit 7e3801c7c6
10 changed files with 585 additions and 151 deletions
+171
View File
@@ -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")
}
+8 -1
View File
@@ -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 {
+32
View File
@@ -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