mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-02 11:54:41 +00:00
macos: moving some files around
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
90
macos/Sources/Helpers/Extensions/NSImage+Extension.swift
Normal file
90
macos/Sources/Helpers/Extensions/NSImage+Extension.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
macos/Sources/Helpers/Extensions/NSScreen+Extension.swift
Normal file
44
macos/Sources/Helpers/Extensions/NSScreen+Extension.swift
Normal 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
|
||||
}
|
||||
}
|
||||
12
macos/Sources/Helpers/Extensions/NSWindow+Extension.swift
Normal file
12
macos/Sources/Helpers/Extensions/NSWindow+Extension.swift
Normal 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 doesn’t 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)
|
||||
}
|
||||
}
|
||||
104
macos/Sources/Helpers/Extensions/OSColor+Extension.swift
Normal file
104
macos/Sources/Helpers/Extensions/OSColor+Extension.swift
Normal 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 0–255 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)
|
||||
}
|
||||
}
|
||||
20
macos/Sources/Helpers/Extensions/String+Extension.swift
Normal file
20
macos/Sources/Helpers/Extensions/String+Extension.swift
Normal 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
|
||||
}
|
||||
31
macos/Sources/Helpers/Extensions/View+Extension.swift
Normal file
31
macos/Sources/Helpers/Extensions/View+Extension.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user