macos: moving some files around

This commit is contained in:
Mitchell Hashimoto
2025-06-04 19:44:30 -07:00
parent f8e3539b7d
commit 1966dfdef7
14 changed files with 13 additions and 21 deletions

View File

@@ -0,0 +1,27 @@
import SwiftUI
// MARK: EventModifiers to NSEvent and Back
extension EventModifiers {
init(nsFlags: NSEvent.ModifierFlags) {
var result: SwiftUI.EventModifiers = []
if nsFlags.contains(.shift) { result.insert(.shift) }
if nsFlags.contains(.control) { result.insert(.control) }
if nsFlags.contains(.option) { result.insert(.option) }
if nsFlags.contains(.command) { result.insert(.command) }
if nsFlags.contains(.capsLock) { result.insert(.capsLock) }
self = result
}
}
extension NSEvent.ModifierFlags {
init(swiftUIFlags: SwiftUI.EventModifiers) {
var result: NSEvent.ModifierFlags = []
if swiftUIFlags.contains(.shift) { result.insert(.shift) }
if swiftUIFlags.contains(.control) { result.insert(.control) }
if swiftUIFlags.contains(.option) { result.insert(.option) }
if swiftUIFlags.contains(.command) { result.insert(.command) }
if swiftUIFlags.contains(.capsLock) { result.insert(.capsLock) }
self = result
}
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
extension KeyboardShortcut: @retroactive CustomStringConvertible {
public var keyList: [String] {
var result: [String] = []
if modifiers.contains(.control) {
result.append("")
}
if modifiers.contains(.option) {
result.append("")
}
if modifiers.contains(.shift) {
result.append("")
}
if modifiers.contains(.command) {
result.append("")
}
let keyString: String
switch key {
case .return: keyString = ""
case .escape: keyString = ""
case .delete: keyString = ""
case .space: keyString = ""
case .tab: keyString = ""
case .upArrow: keyString = ""
case .downArrow: keyString = ""
case .leftArrow: keyString = ""
case .rightArrow: keyString = ""
case .pageUp: keyString = ""
case .pageDown: keyString = ""
case .home: keyString = ""
case .end: keyString = ""
default:
keyString = String(key.character.uppercased())
}
result.append(keyString)
return result
}
public var description: String {
return self.keyList.joined()
}
}
// This is available in macOS 14 so this only applies to early macOS versions.
extension KeyEquivalent: @retroactive Equatable {
public static func == (lhs: KeyEquivalent, rhs: KeyEquivalent) -> Bool {
lhs.character == rhs.character
}
}

View File

@@ -0,0 +1,31 @@
import Cocoa
extension NSAppearance {
/// Returns true if the appearance is some kind of dark.
var isDark: Bool {
return name.rawValue.lowercased().contains("dark")
}
/// Initialize a desired NSAppearance for the Ghostty configuration.
convenience init?(ghosttyConfig config: Ghostty.Config) {
guard let theme = config.windowTheme else { return nil }
switch (theme) {
case "dark":
self.init(named: .darkAqua)
case "light":
self.init(named: .aqua)
case "auto":
let color = OSColor(config.backgroundColor)
if color.isLightColor {
self.init(named: .aqua)
} else {
self.init(named: .darkAqua)
}
default:
return nil
}
}
}

View File

@@ -0,0 +1,43 @@
import Cocoa
// MARK: Presentation Options
extension NSApplication {
private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:]
/// Add a presentation option to the application and main a reference count so that and equal
/// number of pops is required to disable it. This is useful so that multiple classes can affect global
/// app state without overriding others.
func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
Self.presentationOptionCounts[option, default: 0] += 1
presentationOptions.insert(option)
}
/// See acquirePresentationOption
func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
guard let value = Self.presentationOptionCounts[option] else { return }
guard value > 0 else { return }
if (value == 1) {
presentationOptions.remove(option)
Self.presentationOptionCounts.removeValue(forKey: option)
} else {
Self.presentationOptionCounts[option] = value - 1
}
}
}
extension NSApplication.PresentationOptions.Element: @retroactive Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(rawValue)
}
}
// MARK: Frontmost
extension NSApplication {
/// True if the application is frontmost. This isn't exactly the same as isActive because
/// an app can be active but not be frontmost if the window with activity is an NSPanel.
var isFrontmost: Bool {
NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier
}
}

View File

@@ -0,0 +1,90 @@
import Cocoa
extension NSImage {
/// Combine multiple images with the given blend modes. This is useful given a set
/// of layers to create a final rasterized image.
static func combine(images: [NSImage], blendingModes: [CGBlendMode]) -> NSImage? {
guard images.count == blendingModes.count else { return nil }
guard images.count > 0 else { return nil }
// The final size will be the same size as our first image.
let size = images.first!.size
// Create a bitmap context manually
guard let bitmapContext = CGContext(
data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
// Clear the context
bitmapContext.setFillColor(.clear)
bitmapContext.fill(.init(origin: .zero, size: size))
// Draw each image with its corresponding blend mode
for (index, image) in images.enumerated() {
guard let cgImage = image.cgImage(
forProposedRect: nil,
context: nil,
hints: nil
) else { return nil }
let blendMode = blendingModes[index]
bitmapContext.setBlendMode(blendMode)
bitmapContext.draw(cgImage, in: CGRect(origin: .zero, size: size))
}
// Create a CGImage from the context
guard let combinedCGImage = bitmapContext.makeImage() else { return nil }
// Wrap the CGImage in an NSImage
return NSImage(cgImage: combinedCGImage, size: size)
}
/// Apply a gradient onto this image, using this image as a mask.
func gradient(colors: [NSColor]) -> NSImage? {
let resultImage = NSImage(size: size)
resultImage.lockFocus()
defer { resultImage.unlockFocus() }
// Draw the gradient
guard let gradient = NSGradient(colors: colors) else { return nil }
gradient.draw(in: .init(origin: .zero, size: size), angle: 90)
// Apply the mask
draw(at: .zero, from: .zero, operation: .destinationIn, fraction: 1.0)
return resultImage
}
// Tint an NSImage with the given color by applying a basic fill on top of it.
func tint(color: NSColor) -> NSImage? {
// Create a new image with the same size as the base image
let newImage = NSImage(size: size)
// Draw into the new image
newImage.lockFocus()
defer { newImage.unlockFocus() }
// Set up the drawing context
guard let context = NSGraphicsContext.current?.cgContext else { return nil }
defer { context.restoreGState() }
// Draw the base image
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
context.draw(cgImage, in: .init(origin: .zero, size: size))
// Set the tint color and blend mode
context.setFillColor(color.cgColor)
context.setBlendMode(.sourceAtop)
// Apply the tint color over the entire image
context.fill(.init(origin: .zero, size: size))
return newImage
}
}

View File

@@ -0,0 +1,39 @@
import AppKit
import GhosttyKit
extension NSPasteboard {
/// The pasteboard to used for Ghostty selection.
static var ghosttySelection: NSPasteboard = {
NSPasteboard(name: .init("com.mitchellh.ghostty.selection"))
}()
/// Gets the contents of the pasteboard as a string following a specific set of semantics.
/// Does these things in order:
/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard.
/// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? {
if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
return urls
.map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString }
.joined(separator: " ")
}
return self.string(forType: .string)
}
/// The pasteboard for the Ghostty enum type.
static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? {
switch (clipboard) {
case GHOSTTY_CLIPBOARD_STANDARD:
return Self.general
case GHOSTTY_CLIPBOARD_SELECTION:
return Self.ghosttySelection
default:
return nil
}
}
}

View File

@@ -0,0 +1,44 @@
import Cocoa
extension NSScreen {
/// The unique CoreGraphics display ID for this screen.
var displayID: UInt32? {
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
}
// Returns true if the given screen has a visible dock. This isn't
// point-in-time visible, this is true if the dock is always visible
// AND present on this screen.
var hasDock: Bool {
// If the dock autohides then we don't have a dock ever.
if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool {
if (dockAutohide) { return false }
}
// There is no public API to directly ask about dock visibility, so we have to figure it out
// by comparing the sizes of visibleFrame (the currently usable area of the screen) and
// frame (the full screen size). We also need to account for the menubar, any inset caused
// by the notch on macbooks, and a little extra padding to compensate for the boundary area
// which triggers showing the dock.
// If our visible width is less than the frame we assume its the dock.
if (visibleFrame.width < frame.width) {
return true
}
// We need to see if our visible frame height is less than the full
// screen height minus the menu and notch and such.
let menuHeight = NSApp.mainMenu?.menuBarHeight ?? 0
let notchInset: CGFloat = safeAreaInsets.top
let boundaryAreaPadding = 5.0
return visibleFrame.height < (frame.height - max(menuHeight, notchInset) - boundaryAreaPadding)
}
/// Returns true if the screen has a visible notch (i.e., a non-zero safe area inset at the top).
var hasNotch: Bool {
// We assume that a top safe area means notch, since we don't currently
// know any other situation this is true.
return safeAreaInsets.top > 0
}
}

View File

@@ -0,0 +1,12 @@
import AppKit
extension NSWindow {
/// Get the CGWindowID type for the window (used for low level CoreGraphics APIs).
var cgWindowId: CGWindowID? {
// "If the window doesnt have a window device, the value of this
// property is equal to or less than 0." - Docs. In practice I've
// found this is true if a window is not visible.
guard windowNumber > 0 else { return nil }
return CGWindowID(windowNumber)
}
}

View File

@@ -0,0 +1,104 @@
import Foundation
import GhosttyKit
extension OSColor {
var isLightColor: Bool {
return self.luminance > 0.5
}
var luminance: Double {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
// getRed:green:blue:alpha requires sRGB space
#if canImport(AppKit)
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }
#else
let rgb = self
#endif
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
return (0.299 * r) + (0.587 * g) + (0.114 * b)
}
var hexString: String? {
#if canImport(AppKit)
guard let rgb = usingColorSpace(.deviceRGB) else { return nil }
let red = Int(rgb.redComponent * 255)
let green = Int(rgb.greenComponent * 255)
let blue = Int(rgb.blueComponent * 255)
return String(format: "#%02X%02X%02X", red, green, blue)
#elseif canImport(UIKit)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
// Convert to 0255 range
let r = Int(red * 255)
let g = Int(green * 255)
let b = Int(blue * 255)
// Format to hexadecimal
return String(format: "#%02X%02X%02X", r, g, b)
#endif
}
/// Create an OSColor from a hex string.
convenience init?(hex: String) {
var cleanedHex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove `#` if present
if cleanedHex.hasPrefix("#") {
cleanedHex.removeFirst()
}
guard cleanedHex.count == 6 || cleanedHex.count == 8 else { return nil }
let scanner = Scanner(string: cleanedHex)
var hexNumber: UInt64 = 0
guard scanner.scanHexInt64(&hexNumber) else { return nil }
let red, green, blue, alpha: CGFloat
if cleanedHex.count == 8 {
alpha = CGFloat((hexNumber & 0xFF000000) >> 24) / 255
red = CGFloat((hexNumber & 0x00FF0000) >> 16) / 255
green = CGFloat((hexNumber & 0x0000FF00) >> 8) / 255
blue = CGFloat(hexNumber & 0x000000FF) / 255
} else { // 6 characters
alpha = 1.0
red = CGFloat((hexNumber & 0xFF0000) >> 16) / 255
green = CGFloat((hexNumber & 0x00FF00) >> 8) / 255
blue = CGFloat(hexNumber & 0x0000FF) / 255
}
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
func darken(by amount: CGFloat) -> OSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return OSColor(
hue: h,
saturation: s,
brightness: min(b * (1 - amount), 1),
alpha: a
)
}
}
// MARK: Ghostty Types
extension OSColor {
/// Create a color from a Ghostty color.
convenience init(ghostty: ghostty_config_color_s) {
let red = Double(ghostty.r) / 255
let green = Double(ghostty.g) / 255
let blue = Double(ghostty.b) / 255
self.init(red: red, green: green, blue: blue, alpha: 1)
}
}

View File

@@ -0,0 +1,20 @@
extension String {
func truncate(length: Int, trailing: String = "") -> String {
let maxLength = length - trailing.count
guard maxLength > 0, !self.isEmpty, self.count > length else {
return self
}
return self.prefix(maxLength) + trailing
}
#if canImport(AppKit)
func temporaryFile(_ filename: String = "temp") -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(filename)
.appendingPathExtension("txt")
let string = self
try? string.write(to: url, atomically: true, encoding: .utf8)
return url
}
#endif
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
extension View {
func innerShadow<S: Shape, ST: ShapeStyle>(
using shape: S = Rectangle(),
stroke: ST = Color.black,
width: CGFloat = 6,
blur: CGFloat = 6
) -> some View {
return self
.overlay(
shape
.stroke(stroke, lineWidth: width)
.blur(radius: blur)
.mask(shape)
)
}
}
extension View {
func pointerStyleFromCursor(_ cursor: NSCursor) -> some View {
if #available(macOS 15.0, *) {
return self.pointerStyle(.image(
Image(nsImage: cursor.image),
hotSpot: .init(x: cursor.hotSpot.x, y: cursor.hotSpot.y)
))
} else {
return self
}
}
}