Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,150 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
) != nil
}

@discardableResult
func startOrFocusTerminalSearch(
_ terminalSurface: TerminalSurface,
searchFocusNotifier: @escaping (TerminalSurface) -> Void = {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0)
}
) -> Bool {
if terminalSurface.searchState != nil {
searchFocusNotifier(terminalSurface)
return true
}

if terminalSurface.performBindingAction("start_search") {
DispatchQueue.main.async { [weak terminalSurface] in
guard let terminalSurface, terminalSurface.searchState == nil else { return }
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
}
return true
}

terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
return true
}

enum TerminalFindShortcutAction {
case start
case next
case previous
case hide
case selection

#if DEBUG
var debugName: String {
switch self {
case .start: return "start"
case .next: return "next"
case .previous: return "previous"
case .hide: return "hide"
case .selection: return "selection"
}
}
#endif
}

func terminalFindShortcutAction(
flags: NSEvent.ModifierFlags,
chars: String,
keyCode: UInt16,
layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
) -> TerminalFindShortcutAction? {
let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask)
guard normalizedFlags.contains(.command),
!normalizedFlags.contains(.option),
!normalizedFlags.contains(.control) else {
return nil
}

let normalizedChars = chars.lowercased()
let charsAreControlSequence = !normalizedChars.isEmpty
&& normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) }

let shortcutCharacter: String? = {
if let translated = layoutCharacterProvider(keyCode, normalizedFlags),
!translated.isEmpty {
return translated.lowercased()
}
if !normalizedChars.isEmpty && !charsAreControlSequence {
return normalizedChars
}
switch keyCode {
case 3:
return "f"
case 5:
return "g"
case 14:
return "e"
default:
return nil
}
}()

switch (shortcutCharacter, normalizedFlags.contains(.shift)) {
case ("f"?, false):
return .start
case ("g"?, false):
return .next
case ("g"?, true):
return .previous
case ("f"?, true):
return .hide
case ("e"?, false):
return .selection
default:
return nil
}
}

@discardableResult
func handleTerminalFindShortcutEquivalent(
event: NSEvent,
ghosttyView: GhosttyNSView,
mainMenu: NSMenu? = NSApp.mainMenu
) -> Bool {
guard let action = terminalFindShortcutAction(
flags: event.modifierFlags,
chars: event.charactersIgnoringModifiers ?? "",
keyCode: event.keyCode
) else {
return false
}

if let mainMenu, mainMenu.performKeyEquivalent(with: event) {
return true
}

guard let terminalSurface = ghosttyView.terminalSurface else { return false }
switch action {
case .start:
_ = startOrFocusTerminalSearch(terminalSurface)
case .next:
_ = terminalSurface.performBindingAction("search:next")
case .previous:
_ = terminalSurface.performBindingAction("search:previous")
case .hide:
terminalSurface.searchState = nil
case .selection:
if terminalSurface.searchState == nil {
terminalSurface.searchState = TerminalSurface.SearchState()
}
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
_ = terminalSurface.performBindingAction("search_selection")
}

#if DEBUG
dlog(
"find.shortcut.directFallback action=\(action.debugName) " +
"surface=\(terminalSurface.id.uuidString.prefix(5))"
)
#endif
return true
}

func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
Expand Down Expand Up @@ -11032,6 +11176,10 @@ private extension NSWindow {
#endif
return true
}

if handleTerminalFindShortcutEquivalent(event: event, ghosttyView: ghosttyView) {
return true
}
}

// Web forms rely on Return/Enter flowing through keyDown. If the original
Expand Down
9 changes: 3 additions & 6 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2070,14 +2070,11 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.background({
// The terminal area has two stacked semi-transparent layers: the Bonsplit
// container chrome background plus Ghostty's own Metal-rendered background.
// Compute the effective composited opacity so the titlebar matches visually.
let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity)
let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2)
// Keep titlebar chrome opaque even when the terminal background is translucent.
// Otherwise the native titlebar/tab strip reads as accidentally transparent.
return TitlebarLayerBackground(
backgroundColor: GhosttyApp.shared.defaultBackgroundColor,
opacity: effective
opacity: 1.0
)
}())
.overlay(alignment: .bottom) {
Expand Down
29 changes: 29 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4359,6 +4359,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
#endif

// Keep the macOS Find shortcuts app-owned even when Ghostty also has
// matching surface bindings. If the menu path misses, fall back to the
// exact surface that currently owns first responder.
if handleTerminalFindShortcutEquivalent(event: event, ghosttyView: self) {
return true
}

// Check if this event matches a Ghostty keybinding.
let bindingFlags: ghostty_binding_flags_e? = {
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
Expand Down Expand Up @@ -7143,6 +7150,17 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
if let firstResponder = window.firstResponder,
isCurrentSurfaceSearchFieldResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
#if DEBUG
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
)
#endif
return
}
if let firstResponder = window.firstResponder,
isSearchOverlayOrDescendant(firstResponder),
!isCurrentSurfaceSearchResponder(firstResponder) {
Expand Down Expand Up @@ -7346,6 +7364,17 @@ final class GhosttySurfaceScrollView: NSView {
return view.isDescendant(of: self)
}

private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool {
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSTextField {
return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView)
}

guard let textField = responder as? NSTextField else { return false }
return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField)
}

#if DEBUG
struct DebugRenderStats {
let drawCount: Int
Expand Down
15 changes: 8 additions & 7 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -753,15 +753,16 @@ class TabManager: ObservableObject {
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
let hadExistingSearch = panel.searchState != nil
let handled = startOrFocusTerminalSearch(panel.surface)
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
dlog(
"find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " +
"panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " +
"handled=\(handled ? 1 : 0) " +
"firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))"
)
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}

func searchSelection() {
Expand Down
11 changes: 5 additions & 6 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1033,12 +1033,11 @@ final class Workspace: Identifiable, ObservableObject {
}

static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
let themedColor = GhosttyBackgroundTheme.color(
backgroundColor: backgroundColor,
opacity: backgroundOpacity
)
let includeAlpha = themedColor.alphaComponent < 0.999
return themedColor.hexString(includeAlpha: includeAlpha)
_ = backgroundOpacity
// Terminal translucency should not leak into window chrome. Bonsplit's tab strip
// and split backgrounds need an opaque theme color so the titlebar and tabs do not
// read as transparent when the terminal background uses alpha.
return backgroundColor.withAlphaComponent(1.0).hexString()
}

nonisolated static func resolvedChromeColors(
Expand Down
Loading