macOS: Session Search (#9945)
Replaces #9785 This adds session search to the command palette on macOS. Session search lets you jump to any running terminal based on title or working directory. The command palette shows you the title, working directory, and tab color (if any) to help you identify the terminal you care about. This also enhances our command palette in general to support better sorting, more stable sorts when keys are identical, etc. ## Demo https://github.com/user-attachments/assets/602a9424-e182-4651-bf08-378e9c5e1616 ## Future Since this inherits the command palette infrastructure, we don't have fuzzy search capabilities yet, but I still think this is useful already. We should add fuzzy searching in the future. Thanks @phaistonian for the inspiration here but I did do a full rewrite. **AI disclosure:** I used AI to assist with this in places, but I understand everything and touched up almost everything manually.main tip
commit
a4cb73db84
|
|
@ -157,6 +157,7 @@
|
|||
"Helpers/Extensions/KeyboardShortcut+Extension.swift",
|
||||
"Helpers/Extensions/NSAppearance+Extension.swift",
|
||||
"Helpers/Extensions/NSApplication+Extension.swift",
|
||||
"Helpers/Extensions/NSColor+Extension.swift",
|
||||
"Helpers/Extensions/NSImage+Extension.swift",
|
||||
"Helpers/Extensions/NSMenu+Extension.swift",
|
||||
"Helpers/Extensions/NSMenuItem+Extension.swift",
|
||||
|
|
|
|||
|
|
@ -1,30 +1,50 @@
|
|||
import SwiftUI
|
||||
|
||||
struct CommandOption: Identifiable, Hashable {
|
||||
/// Unique identifier for this option.
|
||||
let id = UUID()
|
||||
/// The primary text displayed for this command.
|
||||
let title: String
|
||||
/// Secondary text displayed below the title.
|
||||
let subtitle: String?
|
||||
/// Tooltip text shown on hover.
|
||||
let description: String?
|
||||
/// Keyboard shortcut symbols to display.
|
||||
let symbols: [String]?
|
||||
/// SF Symbol name for the leading icon.
|
||||
let leadingIcon: String?
|
||||
/// Color for the leading indicator circle.
|
||||
let leadingColor: Color?
|
||||
/// Badge text displayed as a pill.
|
||||
let badge: String?
|
||||
/// Whether to visually emphasize this option.
|
||||
let emphasis: Bool
|
||||
/// Sort key for stable ordering when titles are equal.
|
||||
let sortKey: AnySortKey?
|
||||
/// The action to perform when this option is selected.
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
description: String? = nil,
|
||||
symbols: [String]? = nil,
|
||||
leadingIcon: String? = nil,
|
||||
leadingColor: Color? = nil,
|
||||
badge: String? = nil,
|
||||
emphasis: Bool = false,
|
||||
sortKey: AnySortKey? = nil,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.description = description
|
||||
self.symbols = symbols
|
||||
self.leadingIcon = leadingIcon
|
||||
self.leadingColor = leadingColor
|
||||
self.badge = badge
|
||||
self.emphasis = emphasis
|
||||
self.sortKey = sortKey
|
||||
self.action = action
|
||||
}
|
||||
|
||||
|
|
@ -47,12 +67,24 @@ struct CommandPaletteView: View {
|
|||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
// The options that we should show, taking into account any filtering from
|
||||
// the query.
|
||||
// the query. Options with matching leadingColor are ranked higher.
|
||||
var filteredOptions: [CommandOption] {
|
||||
if query.isEmpty {
|
||||
return options
|
||||
} else {
|
||||
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
||||
// Filter by title/subtitle match OR color match
|
||||
let filtered = options.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(query) ||
|
||||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
|
||||
colorMatchScore(for: $0.leadingColor, query: query) > 0
|
||||
}
|
||||
|
||||
// Sort by color match score (higher scores first), then maintain original order
|
||||
return filtered.sorted { a, b in
|
||||
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
|
||||
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
|
||||
return scoreA > scoreB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +200,32 @@ struct CommandPaletteView: View {
|
|||
isTextFieldFocused = isPresented
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
|
||||
/// Returns 0 if no color name in the query matches, or if the color is nil.
|
||||
private func colorMatchScore(for color: Color?, query: String) -> Double {
|
||||
guard let color = color else { return 0 }
|
||||
|
||||
let queryLower = query.lowercased()
|
||||
let nsColor = NSColor(color)
|
||||
|
||||
var bestScore: Double = 0
|
||||
for name in NSColor.colorNames {
|
||||
guard queryLower.contains(name),
|
||||
let systemColor = NSColor(named: name) else { continue }
|
||||
|
||||
let distance = nsColor.distance(to: systemColor)
|
||||
// Max distance in weighted RGB space is ~3.0, so normalize and invert
|
||||
// Use a threshold to determine "close enough" matches
|
||||
let maxDistance: Double = 1.5
|
||||
if distance < maxDistance {
|
||||
let score = 1.0 - (distance / maxDistance)
|
||||
bestScore = max(bestScore, score)
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field for building the query for the command palette.
|
||||
|
|
@ -283,14 +341,28 @@ fileprivate struct CommandRow: View {
|
|||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
if let color = option.leadingColor {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
if let icon = option.leadingIcon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
|
||||
Text(option.title)
|
||||
.fontWeight(option.emphasis ? .medium : .regular)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(option.title)
|
||||
.fontWeight(option.emphasis ? .medium : .regular)
|
||||
|
||||
if let subtitle = option.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View {
|
|||
/// The callback when an action is submitted.
|
||||
var onAction: ((String) -> Void)
|
||||
|
||||
// The commands available to the command palette.
|
||||
private var commandOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
|
||||
// Add update command if an update is installable. This must always be the first so
|
||||
// it is at the top.
|
||||
if let updateViewModel, updateViewModel.state.isInstallable {
|
||||
// We override the update available one only because we want to properly
|
||||
// convey it'll go all the way through.
|
||||
let title: String
|
||||
if case .updateAvailable = updateViewModel.state {
|
||||
title = "Update Ghostty and Restart"
|
||||
} else {
|
||||
title = updateViewModel.text
|
||||
}
|
||||
|
||||
options.append(CommandOption(
|
||||
title: title,
|
||||
description: updateViewModel.description,
|
||||
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
|
||||
badge: updateViewModel.badge,
|
||||
emphasis: true
|
||||
) {
|
||||
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
|
||||
})
|
||||
}
|
||||
|
||||
// Add cancel/skip update command if the update is installable
|
||||
if let updateViewModel, updateViewModel.state.isInstallable {
|
||||
options.append(CommandOption(
|
||||
title: "Cancel or Skip Update",
|
||||
description: "Dismiss the current update process"
|
||||
) {
|
||||
updateViewModel.state.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
// Add terminal commands
|
||||
guard let surface = surfaceView.surfaceModel else { return options }
|
||||
do {
|
||||
let terminalCommands = try surface.commands().map { c in
|
||||
return CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
options.append(contentsOf: terminalCommands)
|
||||
} catch {
|
||||
return options
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isPresented {
|
||||
|
|
@ -116,6 +59,121 @@ struct TerminalCommandPaletteView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All commands available in the command palette, combining update and terminal options.
|
||||
private var commandOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
// Updates always appear first
|
||||
options.append(contentsOf: updateOptions)
|
||||
|
||||
// Sort the rest. We replace ":" with a character that sorts before space
|
||||
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
|
||||
// for stable ordering when titles are equal.
|
||||
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
|
||||
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
|
||||
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
|
||||
let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized)
|
||||
if comparison != .orderedSame {
|
||||
return comparison == .orderedAscending
|
||||
}
|
||||
// Tie-breaker: use sortKey if both have one
|
||||
if let aSortKey = a.sortKey, let bSortKey = b.sortKey {
|
||||
return aSortKey < bSortKey
|
||||
}
|
||||
return false
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
/// Commands for installing or canceling available updates.
|
||||
private var updateOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
|
||||
guard let updateViewModel, updateViewModel.state.isInstallable else {
|
||||
return options
|
||||
}
|
||||
|
||||
// We override the update available one only because we want to properly
|
||||
// convey it'll go all the way through.
|
||||
let title: String
|
||||
if case .updateAvailable = updateViewModel.state {
|
||||
title = "Update Ghostty and Restart"
|
||||
} else {
|
||||
title = updateViewModel.text
|
||||
}
|
||||
|
||||
options.append(CommandOption(
|
||||
title: title,
|
||||
description: updateViewModel.description,
|
||||
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
|
||||
badge: updateViewModel.badge,
|
||||
emphasis: true
|
||||
) {
|
||||
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
|
||||
})
|
||||
|
||||
options.append(CommandOption(
|
||||
title: "Cancel or Skip Update",
|
||||
description: "Dismiss the current update process"
|
||||
) {
|
||||
updateViewModel.state.cancel()
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/// Commands exposed by the terminal surface.
|
||||
private var terminalOptions: [CommandOption] {
|
||||
guard let surface = surfaceView.surfaceModel else { return [] }
|
||||
do {
|
||||
return try surface.commands().map { c in
|
||||
CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList,
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands for jumping to other terminal surfaces.
|
||||
private var jumpOptions: [CommandOption] {
|
||||
TerminalController.all.flatMap { controller -> [CommandOption] in
|
||||
guard let window = controller.window else { return [] }
|
||||
|
||||
let color = (window as? TerminalWindow)?.tabColor
|
||||
let displayColor = color != TerminalTabColor.none ? color : nil
|
||||
|
||||
return controller.surfaceTree.map { surface in
|
||||
let title = surface.title.isEmpty ? window.title : surface.title
|
||||
let displayTitle = title.isEmpty ? "Untitled" : title
|
||||
let pwd = surface.pwd?.abbreviatedPath
|
||||
let subtitle: String? = if let pwd, !displayTitle.contains(pwd) {
|
||||
pwd
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return CommandOption(
|
||||
title: "Focus: \(displayTitle)",
|
||||
subtitle: subtitle,
|
||||
leadingIcon: "rectangle.on.rectangle",
|
||||
leadingColor: displayColor?.displayColor.map { Color($0) },
|
||||
sortKey: AnySortKey(ObjectIdentifier(surface))
|
||||
) {
|
||||
NotificationCenter.default.post(
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// This is done to ensure that the given view is in the responder chain.
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ class BaseTerminalController: NSWindowController,
|
|||
selector: #selector(ghosttyDidResizeSplit(_:)),
|
||||
name: Ghostty.Notification.didResizeSplit,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidPresentTerminal(_:)),
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: nil)
|
||||
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
|
|
@ -700,6 +705,22 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidPresentTerminal(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Bring the window to front and focus the surface.
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
|
||||
// We use a small delay to ensure this runs after any UI cleanup
|
||||
// (e.g., command palette restoring focus to its original surface).
|
||||
Ghostty.moveFocus(to: target)
|
||||
Ghostty.moveFocus(to: target, delay: 0.1)
|
||||
|
||||
// Show a brief highlight to help the user locate the presented terminal.
|
||||
target.highlight()
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
||||
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
||||
|
|
|
|||
|
|
@ -627,12 +627,13 @@ extension Ghostty {
|
|||
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
||||
searchSelected(app, target: target, v: action.action.search_selected)
|
||||
|
||||
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
||||
return presentTerminal(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_SIZE_LIMIT:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_QUIT_TIMER:
|
||||
|
|
@ -845,6 +846,30 @@ extension Ghostty {
|
|||
}
|
||||
}
|
||||
|
||||
private static func presentTerminal(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s
|
||||
) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyPresentTerminal,
|
||||
object: surfaceView
|
||||
)
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
|
|
|
|||
|
|
@ -435,6 +435,9 @@ extension Ghostty.Notification {
|
|||
/// New window. Has base surface config requested in userinfo.
|
||||
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")
|
||||
|
||||
/// Present terminal. Bring the surface's window to focus without activating the app.
|
||||
static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal")
|
||||
|
||||
/// Toggle fullscreen of current window
|
||||
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
|
||||
static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ extension Ghostty {
|
|||
|
||||
// True if we're hovering over the left URL view, so we can show it on the right.
|
||||
@State private var isHoveringURLLeft: Bool = false
|
||||
|
||||
|
||||
#if canImport(AppKit)
|
||||
// Observe SecureInput to detect when its enabled
|
||||
@ObservedObject private var secureInput = SecureInput.shared
|
||||
|
|
@ -219,6 +219,9 @@ extension Ghostty {
|
|||
BellBorderOverlay(bell: surfaceView.bell)
|
||||
}
|
||||
|
||||
// Show a highlight effect when this surface needs attention
|
||||
HighlightOverlay(highlighted: surfaceView.highlighted)
|
||||
|
||||
// If our surface is not healthy, then we render an error view over it.
|
||||
if (!surfaceView.healthy) {
|
||||
Rectangle().fill(ghostty.config.backgroundColor)
|
||||
|
|
@ -242,6 +245,7 @@ extension Ghostty {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -764,6 +768,62 @@ extension Ghostty {
|
|||
}
|
||||
}
|
||||
|
||||
/// Visual overlay that briefly highlights a surface to draw attention to it.
|
||||
/// Uses a soft, soothing highlight with a pulsing border effect.
|
||||
struct HighlightOverlay: View {
|
||||
let highlighted: Bool
|
||||
|
||||
@State private var borderPulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.accentColor.opacity(0.12),
|
||||
Color.accentColor.opacity(0.03),
|
||||
Color.clear
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 2000
|
||||
)
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.accentColor.opacity(0.8),
|
||||
Color.accentColor.opacity(0.5),
|
||||
Color.accentColor.opacity(0.8)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: borderPulse ? 4 : 2
|
||||
)
|
||||
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0)
|
||||
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.opacity(highlighted ? 1.0 : 0.0)
|
||||
.animation(.easeOut(duration: 0.4), value: highlighted)
|
||||
.onChange(of: highlighted) { newValue in
|
||||
if newValue {
|
||||
withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) {
|
||||
borderPulse = true
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
borderPulse = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Readonly Badge
|
||||
|
||||
/// A badge overlay that indicates a surface is in readonly mode.
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ extension Ghostty {
|
|||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
|
||||
@Published private(set) var highlighted: Bool = false
|
||||
|
||||
// An initial size to request for a window. This will only affect
|
||||
// then the view is moved to a new window.
|
||||
var initialSize: NSSize? = nil
|
||||
|
|
@ -1523,6 +1526,14 @@ extension Ghostty {
|
|||
}
|
||||
}
|
||||
|
||||
/// Triggers a brief highlight animation on this surface.
|
||||
func highlight() {
|
||||
highlighted = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||||
self?.highlighted = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func splitRight(_ sender: Any) {
|
||||
guard let surface = self.surface else { return }
|
||||
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ extension Ghostty {
|
|||
|
||||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
|
||||
@Published private(set) var highlighted: Bool = false
|
||||
|
||||
// Returns sizing information for the surface. This is the raw C
|
||||
// structure because I'm lazy.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import Foundation
|
||||
|
||||
/// Type-erased wrapper for any Comparable type to use as a sort key.
|
||||
struct AnySortKey: Comparable {
|
||||
private let value: Any
|
||||
private let comparator: (Any, Any) -> ComparisonResult
|
||||
|
||||
init<T: Comparable>(_ value: T) {
|
||||
self.value = value
|
||||
self.comparator = { lhs, rhs in
|
||||
guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame }
|
||||
if l < r { return .orderedAscending }
|
||||
if l > r { return .orderedDescending }
|
||||
return .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
|
||||
lhs.comparator(lhs.value, rhs.value) == .orderedAscending
|
||||
}
|
||||
|
||||
static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
|
||||
lhs.comparator(lhs.value, rhs.value) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import AppKit
|
||||
|
||||
extension NSColor {
|
||||
/// Using a color list let's us get localized names.
|
||||
private static let appleColorList: NSColorList? = NSColorList(named: "Apple")
|
||||
|
||||
convenience init?(named name: String) {
|
||||
guard let colorList = Self.appleColorList,
|
||||
let color = colorList.color(withKey: name.capitalized) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = color.usingColorSpace(.sRGB) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
red: components.redComponent,
|
||||
green: components.greenComponent,
|
||||
blue: components.blueComponent,
|
||||
alpha: components.alphaComponent
|
||||
)
|
||||
}
|
||||
|
||||
static var colorNames: [String] {
|
||||
appleColorList?.allKeys.map { $0.lowercased() } ?? []
|
||||
}
|
||||
|
||||
/// Calculates the perceptual distance to another color in RGB space.
|
||||
func distance(to other: NSColor) -> Double {
|
||||
guard let a = self.usingColorSpace(.sRGB),
|
||||
let b = other.usingColorSpace(.sRGB) else { return .infinity }
|
||||
|
||||
let dr = a.redComponent - b.redComponent
|
||||
let dg = a.greenComponent - b.greenComponent
|
||||
let db = a.blueComponent - b.blueComponent
|
||||
|
||||
// Weighted Euclidean distance (human eye is more sensitive to green)
|
||||
return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ extension String {
|
|||
return self.prefix(maxLength) + trailing
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
#if canImport(AppKit)
|
||||
func temporaryFile(_ filename: String = "temp") -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(filename)
|
||||
|
|
@ -16,5 +16,14 @@ extension String {
|
|||
try? string.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns the path with the home directory abbreviated as ~.
|
||||
var abbreviatedPath: String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
if hasPrefix(home) {
|
||||
return "~" + dropFirst(home.count)
|
||||
}
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue