diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 562166c87..1a810e621 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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", diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 79c3ca756..235881dde 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.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() diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 96ff3d0c1..19bedd4ee 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -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. diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5f067c128..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -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? { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9d039d4..3348ab714 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b834ea31f..375e5c37b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 82232dd89..49c6a4982 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d26545ebc..88a0bb6e8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 568a93314..b2e429455 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -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. diff --git a/macos/Sources/Helpers/AnySortKey.swift b/macos/Sources/Helpers/AnySortKey.swift new file mode 100644 index 000000000..6813ccf45 --- /dev/null +++ b/macos/Sources/Helpers/AnySortKey.swift @@ -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(_ 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 + } +} diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift new file mode 100644 index 000000000..63cf02ed4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -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) + } +} diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 0c1c4fe91..139a7892c 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -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 }