import Bonsplit import SwiftUI import WebKit import AppKit enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { case wrenchAndScrewdriver = "wrench.and.screwdriver" case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill" case curlyBracesSquare = "curlybraces.square" case curlyBraces = "curlybraces" case terminalFill = "terminal.fill" case terminal = "terminal" case hammer = "hammer" case hammerCircle = "hammer.circle" case ladybug = "ladybug" case ladybugFill = "ladybug.fill" case scope = "scope" case codeChevrons = "chevron.left.slash.chevron.right" case gearshape = "gearshape" case gearshapeFill = "gearshape.fill" case globe = "globe" case globeAmericas = "globe.americas.fill" var id: String { rawValue } var title: String { switch self { case .wrenchAndScrewdriver: return "Wrench + Screwdriver" case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)" case .curlyBracesSquare: return "Curly Braces" case .curlyBraces: return "Curly Braces (Plain)" case .terminalFill: return "Terminal (Fill)" case .terminal: return "Terminal" case .hammer: return "Hammer" case .hammerCircle: return "Hammer Circle" case .ladybug: return "Bug" case .ladybugFill: return "Bug (Fill)" case .scope: return "Scope" case .codeChevrons: return "Code Chevrons" case .gearshape: return "Gear" case .gearshapeFill: return "Gear (Fill)" case .globe: return "Globe" case .globeAmericas: return "Globe Americas (Fill)" } } } enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { case bonsplitInactive case bonsplitActive case accent case tertiary var id: String { rawValue } var title: String { switch self { case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" case .accent: return "Accent" case .tertiary: return "Tertiary" } } var color: Color { switch self { case .bonsplitInactive: // Matches Bonsplit tab icon tint for inactive tabs. return Color(nsColor: .secondaryLabelColor) case .bonsplitActive: // Matches Bonsplit tab icon tint for active tabs. return Color(nsColor: .labelColor) case .accent: return cmuxAccentColor() case .tertiary: return Color(nsColor: .tertiaryLabelColor) } } } enum BrowserDevToolsButtonDebugSettings { static let iconNameKey = "browserDevToolsIconName" static let iconColorKey = "browserDevToolsIconColor" static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { guard let raw = defaults.string(forKey: iconNameKey), let option = BrowserDevToolsIconOption(rawValue: raw) else { return defaultIcon } return option } static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { guard let raw = defaults.string(forKey: iconColorKey), let option = BrowserDevToolsIconColorOption(rawValue: raw) else { return defaultColor } return option } static func copyPayload(defaults: UserDefaults = .standard) -> String { let icon = iconOption(defaults: defaults) let color = colorOption(defaults: defaults) return """ browserDevToolsIconName=\(icon.rawValue) browserDevToolsIconColor=\(color.rawValue) """ } } struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String let acceptedText: String var suffixRange: NSRange { let typedCount = typedText.utf16.count let fullCount = displayText.utf16.count return NSRange(location: typedCount, length: max(0, fullCount - typedCount)) } } private struct OmnibarAddressButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { OmnibarAddressButtonStyleBody(configuration: configuration) } } private struct OmnibarAddressButtonStyleBody: View { let configuration: OmnibarAddressButtonStyle.Configuration @Environment(\.isEnabled) private var isEnabled @State private var isHovered = false private var backgroundOpacity: Double { guard isEnabled else { return 0.0 } if configuration.isPressed { return 0.16 } if isHovered { return 0.08 } return 0.0 } var body: some View { configuration.label .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.primary.opacity(backgroundOpacity)) ) .onHover { hovering in isHovered = hovering } .animation(.easeOut(duration: 0.12), value: isHovered) .animation(.easeOut(duration: 0.08), value: configuration.isPressed) } } private extension View { func cmuxFlatSymbolColorRendering() -> some View { // `symbolColorRenderingMode(.flat)` is not available in the current SDK // used by CI/local builds. Keep this modifier as a compatibility no-op. self } } func resolvedBrowserChromeBackgroundColor( for colorScheme: ColorScheme, themeBackgroundColor: NSColor ) -> NSColor { switch colorScheme { case .dark, .light: return themeBackgroundColor @unknown default: return themeBackgroundColor } } func resolvedBrowserChromeColorScheme( for colorScheme: ColorScheme, themeBackgroundColor: NSColor ) -> ColorScheme { let backgroundColor = resolvedBrowserChromeBackgroundColor( for: colorScheme, themeBackgroundColor: themeBackgroundColor ) return backgroundColor.isLightColor ? .light : .dark } func resolvedBrowserOmnibarPillBackgroundColor( for colorScheme: ColorScheme, themeBackgroundColor: NSColor ) -> NSColor { let darkenMix: CGFloat switch colorScheme { case .light: darkenMix = 0.04 case .dark: darkenMix = 0.05 @unknown default: darkenMix = 0.04 } return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor } /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel let isFocused: Bool let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void @Environment(\.colorScheme) private var colorScheme @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @State private var latestRemoteSuggestions: [String] = [] @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @State private var suppressNextFocusLostRevert: Bool = false @State private var focusFlashOpacity: Double = 0.0 @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var isBrowserThemeMenuPresented = false // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 private let addressBarButtonSize: CGFloat = 22 private let addressBarButtonHitSize: CGFloat = 26 private let addressBarVerticalPadding: CGFloat = 4 private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine } private var searchSuggestionsEnabled: Bool { // Touch @AppStorage so SwiftUI invalidates this view when settings change. _ = searchSuggestionsEnabledStorage return BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: .standard) } private var remoteSuggestionsEnabled: Bool { // Deterministic UI-test hook: force remote path on even if a persisted // setting disabled suggestions in previous sessions. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] != nil || UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") != nil { return true } // Keep UI tests deterministic by disabling network suggestions when requested. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] == "1" { return false } return searchSuggestionsEnabled } private var devToolsIconOption: BrowserDevToolsIconOption { BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon } private var devToolsColorOption: BrowserDevToolsIconColorOption { BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } private var browserThemeMode: BrowserThemeMode { BrowserThemeSettings.mode(for: browserThemeModeRaw) } private var browserChromeBackgroundColor: NSColor { resolvedBrowserChromeBackgroundColor( for: colorScheme, themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor ) } private var browserChromeColorScheme: ColorScheme { resolvedBrowserChromeColorScheme( for: colorScheme, themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor ) } private var omnibarPillBackgroundColor: NSColor { resolvedBrowserOmnibarPillBackgroundColor( for: browserChromeColorScheme, themeBackgroundColor: browserChromeBackgroundColor ) } var body: some View { VStack(spacing: 0) { addressBar webView } .overlay { RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .overlay(alignment: .topLeading) { if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 { OmnibarSuggestionsView( engineName: searchEngine.displayName, items: omnibarState.suggestions, selectedIndex: omnibarState.selectedSuggestionIndex, isLoadingRemoteSuggestions: isLoadingRemoteSuggestions, searchSuggestionsEnabled: remoteSuggestionsEnabled, onCommit: { item in commitSuggestion(item) }, onHighlight: { idx in let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx)) applyOmnibarEffects(effects) } ) .frame(width: omnibarPillFrame.width) .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) .environment(\.colorScheme, browserChromeColorScheme) } } .coordinateSpace(name: "BrowserPanelViewSpace") .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in omnibarPillFrame = frame } .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in // Only handle clicks from our own webview. guard let webView = note.object as? CmuxWebView else { return false } return webView === panel?.webView }) { _ in #if DEBUG dlog( "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + "isFocused=\(isFocused ? 1 : 0) " + "addressFocused=\(addressBarFocused ? 1 : 0)" ) #endif onRequestPanelFocus() } .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue } panel.refreshAppearanceDrivenColors() panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() syncURLFromPanel() // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") BrowserHistoryStore.shared.loadIfNeeded() } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() } .onChange(of: panel.currentURL) { _ in let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty syncURLFromPanel() // If we auto-focused a blank omnibar but then a URL loads programmatically, move focus // into WebKit unless the user had already started typing. if addressBarFocused, !panel.shouldSuppressWebViewFocus(), addressWasEmpty, !isWebViewBlank() { addressBarFocused = false } } .onChange(of: browserThemeModeRaw) { _ in let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) if browserThemeModeRaw != normalizedMode.rawValue { browserThemeModeRaw = normalizedMode.rawValue } panel.setBrowserThemeMode(normalizedMode) } .onChange(of: colorScheme) { _ in panel.refreshAppearanceDrivenColors() } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } .onChange(of: isFocused) { focused in // Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive). if focused { applyPendingAddressBarFocusRequestIfNeeded() autoFocusOmnibarIfBlank() } else { hideSuggestions() addressBarFocused = false } syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") } .onChange(of: addressBarFocused) { focused in let urlString = panel.preferredURLStringForOmnibar() ?? "" if focused { panel.beginSuppressWebViewFocusForAddressBar() NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id) // Only request panel focus if this pane isn't currently focused. When already // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. if !isFocused { onRequestPanelFocus() } let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() } else { panel.endSuppressWebViewFocusForAddressBar() NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id) if suppressNextFocusLostRevert { suppressNextFocusLostRevert = false let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString)) applyOmnibarEffects(effects) } else { let effects = omnibarReduce(state: &omnibarState, event: .focusLostRevertBuffer(currentURLString: urlString)) applyOmnibarEffects(effects) } inlineCompletion = nil } syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) applyOmnibarEffects(effects) refreshInlineCompletion() } .onReceive(BrowserHistoryStore.shared.$entries) { _ in guard addressBarFocused else { return } refreshSuggestions() } .onReceive(NotificationCenter.default.publisher(for: .browserDidBlurAddressBar).filter { note in guard let panelId = note.object as? UUID else { return false } return panelId == panel.id }) { _ in if addressBarFocused { addressBarFocused = false } } } private var addressBar: some View { HStack(spacing: 8) { addressBarButtonBar omnibarField .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") if !panel.isShowingNewTabPage { browserThemeModeButton developerToolsButton } } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) .background(Color(nsColor: browserChromeBackgroundColor)) // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) .environment(\.colorScheme, browserChromeColorScheme) } private var addressBarButtonBar: some View { return HStack(spacing: 0) { Button(action: { #if DEBUG dlog("browser.back panel=\(panel.id.uuidString.prefix(5))") #endif panel.goBack() }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") Button(action: { #if DEBUG dlog("browser.forward panel=\(panel.id.uuidString.prefix(5))") #endif panel.goForward() }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") Button(action: { if panel.isLoading { #if DEBUG dlog("browser.stop panel=\(panel.id.uuidString.prefix(5))") #endif panel.stopLoading() } else { #if DEBUG dlog("browser.reload panel=\(panel.id.uuidString.prefix(5))") #endif panel.reload() } }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(OmnibarAddressButtonStyle()) .help(panel.isLoading ? "Stop" : "Reload") if panel.isDownloading { HStack(spacing: 4) { ProgressView() .controlSize(.small) Text("Downloading...") .font(.system(size: 11)) .foregroundStyle(.secondary) } .padding(.leading, 6) .help("Download in progress") } } } private var developerToolsButton: some View { Button(action: { openDevTools() }) { Image(systemName: devToolsIconOption.rawValue) .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() .font(.system(size: devToolsButtonIconSize, weight: .medium)) .foregroundStyle(devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools")) .accessibilityIdentifier("BrowserToggleDevToolsButton") } private var browserThemeModeButton: some View { Button(action: { isBrowserThemeMenuPresented.toggle() }) { Image(systemName: browserThemeMode.iconName) .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() .font(.system(size: devToolsButtonIconSize, weight: .medium)) .foregroundStyle(browserThemeModeIconColor) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { browserThemeModePopover } .help("Browser Theme: \(browserThemeMode.displayName)") .accessibilityIdentifier("BrowserThemeModeButton") } private var browserThemeModePopover: some View { VStack(alignment: .leading, spacing: 2) { ForEach(BrowserThemeMode.allCases) { mode in Button { applyBrowserThemeModeSelection(mode) isBrowserThemeMenuPresented = false } label: { HStack(spacing: 8) { Image(systemName: mode == browserThemeMode ? "checkmark" : "circle") .font(.system(size: 10, weight: .semibold)) .opacity(mode == browserThemeMode ? 1.0 : 0.0) .frame(width: 12, alignment: .center) Text(mode.displayName) .font(.system(size: 12)) Spacer(minLength: 0) } .padding(.horizontal, 8) .frame(height: 24) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(mode == browserThemeMode ? Color.primary.opacity(0.12) : Color.clear) ) } .buttonStyle(.plain) .accessibilityIdentifier("BrowserThemeModeOption\(mode.rawValue.capitalized)") } } .padding(8) .frame(minWidth: 128) } private var browserThemeModeIconColor: Color { devToolsColorOption.color } private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" return HStack(spacing: 4) { if showSecureBadge { Image(systemName: "lock.fill") .font(.system(size: 10)) .foregroundColor(.secondary) } OmnibarTextFieldRepresentable( text: Binding( get: { omnibarState.buffer }, set: { newValue in let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(newValue)) applyOmnibarEffects(effects) refreshInlineCompletion() } ), isFocused: $addressBarFocused, inlineCompletion: inlineCompletion, placeholder: "Search or enter URL", onTap: { handleOmnibarTap() }, onSubmit: { if addressBarFocused, !omnibarState.suggestions.isEmpty { commitSelectedSuggestion() } else { panel.navigateSmart(omnibarState.buffer) hideSuggestions() suppressNextFocusLostRevert = true addressBarFocused = false } }, onEscape: { handleOmnibarEscape() }, onFieldLostFocus: { addressBarFocused = false }, onMoveSelection: { delta in guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) applyOmnibarEffects(effects) refreshInlineCompletion() }, onDeleteSelectedSuggestion: { deleteSelectedSuggestionIfPossible() }, onAcceptInlineCompletion: { acceptInlineCompletion() }, onDeleteBackwardWithInlineSelection: { handleInlineBackspace() }, onSelectionChanged: { selectionRange, hasMarkedText in handleOmnibarSelectionChange(range: selectionRange, hasMarkedText: hasMarkedText) }, shouldSuppressWebViewFocus: { panel.shouldSuppressWebViewFocus() } ) .frame(height: 18) .accessibilityIdentifier("BrowserOmnibarTextField") } .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) .fill(Color(nsColor: omnibarPillBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) .stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1) ) .accessibilityElement(children: .contain) .background { GeometryReader { geo in Color.clear .preference( key: OmnibarPillFramePreferenceKey.self, value: geo.frame(in: .named("BrowserPanelViewSpace")) ) } } } private var webView: some View { Group { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, portalZPriority: portalPriority ) // Keep the representable identity stable across bonsplit structural updates. // This reduces WKWebView reparenting churn (and the associated WebKit crashes). .id(panel.id) .contentShape(Rectangle()) .simultaneousGesture(TapGesture().onEnded { // Chrome-like behavior: clicking web content while editing the // omnibar should commit blur and revert transient edits. if addressBarFocused { addressBarFocused = false } }) } else { Color(nsColor: browserChromeBackgroundColor) .contentShape(Rectangle()) .onTapGesture { onRequestPanelFocus() if addressBarFocused { addressBarFocused = false } } } } .zIndex(0) } private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration focusFlashOpacity = FocusFlashPattern.values.first ?? 0 for segment in FocusFlashPattern.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { focusFlashOpacity = segment.targetOpacity } } } } private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { switch curve { case .easeIn: return .easeIn(duration: duration) case .easeOut: return .easeOut(duration: duration) } } private func syncWebViewResponderPolicyWithViewState(reason: String) { guard let cmuxWebView = panel.webView as? CmuxWebView else { return } let next = isFocused && !panel.shouldSuppressWebViewFocus() if cmuxWebView.allowsFirstResponderAcquisition != next { #if DEBUG dlog( "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + "new=\(next ? 1 : 0) reason=\(reason)" ) #endif } cmuxWebView.allowsFirstResponderAcquisition = next } private func syncURLFromPanel() { let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString)) applyOmnibarEffects(effects) } private func isCommandPaletteVisibleForPanelWindow() -> Bool { guard let app = AppDelegate.shared else { return false } if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) { return true } if let manager = app.tabManagerFor(tabId: panel.workspaceId), let windowId = app.windowId(for: manager), let window = app.mainWindow(for: windowId), app.isCommandPaletteVisible(for: window) { return true } if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { return true } if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { return true } return false } private func applyPendingAddressBarFocusRequestIfNeeded() { guard let requestId = panel.pendingAddressBarFocusRequestId else { return } guard !isCommandPaletteVisibleForPanelWindow() else { return } guard lastHandledAddressBarFocusRequestId != requestId else { return } lastHandledAddressBarFocusRequestId = requestId panel.beginSuppressWebViewFocusForAddressBar() if addressBarFocused { // Re-run focus behavior (select-all/refresh suggestions) when focus is // explicitly requested again while already focused. let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() } else { addressBarFocused = true } panel.acknowledgeAddressBarFocusRequest(requestId) } /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } return url.absoluteString == "about:blank" } private func autoFocusOmnibarIfBlank() { guard isFocused else { return } guard !addressBarFocused else { return } guard !isCommandPaletteVisibleForPanelWindow() else { return } // If a test/automation explicitly focused WebKit, don't steal focus back. guard !panel.shouldSuppressOmnibarAutofocus() else { return } // If a real navigation is underway (e.g. open_browser https://...), don't steal focus. guard !panel.webView.isLoading else { return } guard isWebViewBlank() else { return } addressBarFocused = true } private func openDevTools() { #if DEBUG dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") #endif if !panel.toggleDeveloperTools() { NSSound.beep() } } private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) { if browserThemeModeRaw != mode.rawValue { browserThemeModeRaw = mode.rawValue } panel.setBrowserThemeMode(mode) } private func handleOmnibarTap() { onRequestPanelFocus() guard !addressBarFocused else { return } // `focusPane` converges selection and can transiently move first responder to WebKit. // Reassert omnibar focus on the next runloop for click-to-type behavior. DispatchQueue.main.async { addressBarFocused = true } } private func hideSuggestions() { suggestionTask?.cancel() suggestionTask = nil let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([])) applyOmnibarEffects(effects) isLoadingRemoteSuggestions = false inlineCompletion = nil } private func commitSelectedSuggestion() { let idx = omnibarState.selectedSuggestionIndex guard idx >= 0, idx < omnibarState.suggestions.count else { return } commitSuggestion(omnibarState.suggestions[idx]) } private func commitSuggestion(_ suggestion: OmnibarSuggestion) { // Treat this as a commit, not a user edit: don't refetch suggestions while we're navigating away. omnibarState.buffer = suggestion.completion omnibarState.isUserEditing = false switch suggestion.kind { case .switchToTab(let tabId, let panelId, _, _): AppDelegate.shared?.tabManager?.focusTab(tabId, surfaceId: panelId) default: panel.navigateSmart(suggestion.completion) } hideSuggestions() inlineCompletion = nil suppressNextFocusLostRevert = true addressBarFocused = false } private func handleOmnibarEscape() { guard addressBarFocused else { return } // Chrome-like flow: clear inline completion first, then apply normal escape behavior. if inlineCompletion != nil { inlineCompletion = nil return } let effects = omnibarReduce(state: &omnibarState, event: .escape) applyOmnibarEffects(effects) refreshInlineCompletion() } private func handleOmnibarSelectionChange(range: NSRange, hasMarkedText: Bool) { omnibarSelectionRange = range omnibarHasMarkedText = hasMarkedText refreshInlineCompletion() } private func acceptInlineCompletion() { guard let completion = inlineCompletion else { return } let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(completion.displayText)) applyOmnibarEffects(effects) inlineCompletion = nil } private func handleInlineBackspace() { guard let completion = inlineCompletion else { return } let prefix = completion.typedText guard !prefix.isEmpty else { return } let updated = String(prefix.dropLast()) let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(updated)) applyOmnibarEffects(effects) omnibarSelectionRange = NSRange(location: updated.utf16.count, length: 0) refreshInlineCompletion() } private func deleteSelectedSuggestionIfPossible() { let idx = omnibarState.selectedSuggestionIndex guard idx >= 0, idx < omnibarState.suggestions.count else { return } let target = omnibarState.suggestions[idx] guard case .history(let url, _) = target.kind else { return } guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return } refreshSuggestions() } private func refreshInlineCompletion() { inlineCompletion = omnibarInlineCompletionForDisplay( typedText: omnibarState.buffer, suggestions: omnibarState.suggestions, isFocused: addressBarFocused, selectionRange: omnibarSelectionRange, hasMarkedText: omnibarHasMarkedText ) } private func refreshSuggestions() { suggestionTask?.cancel() suggestionTask = nil isLoadingRemoteSuggestions = false guard addressBarFocused else { let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([])) applyOmnibarEffects(effects) return } let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) let historyEntries: [BrowserHistoryStore.Entry] = { if query.isEmpty { return BrowserHistoryStore.shared.recentSuggestions(limit: 12) } return BrowserHistoryStore.shared.suggestions(for: query, limit: 12) }() let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12) let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil let staleRemote: [String] if query.isEmpty || isSingleCharacterQuery { staleRemote = [] } else { staleRemote = staleRemoteSuggestionsForDisplay(query: query) } let resolvedURL = query.isEmpty ? nil : panel.resolveNavigableURL(from: query) let items = buildOmnibarSuggestions( query: query, engineName: searchEngine.displayName, historyEntries: historyEntries, openTabMatches: openTabMatches, remoteQueries: staleRemote, resolvedURL: resolvedURL, limit: 8 ) let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items)) applyOmnibarEffects(effects) refreshInlineCompletion() guard !query.isEmpty else { return } if !isSingleCharacterQuery, let forcedRemote = forcedRemoteSuggestionsForUITest() { latestRemoteSuggestionQuery = query latestRemoteSuggestions = forcedRemote let merged = buildOmnibarSuggestions( query: query, engineName: searchEngine.displayName, historyEntries: historyEntries, openTabMatches: openTabMatches, remoteQueries: forcedRemote, resolvedURL: resolvedURL, limit: 8 ) let forcedEffects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged)) applyOmnibarEffects(forcedEffects) refreshInlineCompletion() return } guard remoteSuggestionsEnabled else { return } guard !isSingleCharacterQuery else { return } guard omnibarInputIntent(for: query) != .urlLike else { return } // Keep current remote rows visible while fetching fresh predictions. let engine = searchEngine isLoadingRemoteSuggestions = true suggestionTask = Task { let remote = await BrowserSearchSuggestionService.shared.suggestions(engine: engine, query: query) if Task.isCancelled { return } await MainActor.run { guard addressBarFocused else { return } let current = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) guard current == query else { return } latestRemoteSuggestionQuery = query latestRemoteSuggestions = remote let merged = buildOmnibarSuggestions( query: query, engineName: searchEngine.displayName, historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12), openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12), remoteQueries: remote, resolvedURL: panel.resolveNavigableURL(from: query), limit: 8 ) let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged)) applyOmnibarEffects(effects) refreshInlineCompletion() isLoadingRemoteSuggestions = false } } } private func staleRemoteSuggestionsForDisplay(query: String) -> [String] { staleOmnibarRemoteSuggestionsForDisplay( query: query, previousRemoteQuery: latestRemoteSuggestionQuery, previousRemoteSuggestions: latestRemoteSuggestions ) } private func matchingOpenTabSuggestions(for query: String, limit: Int) -> [OmnibarOpenTabMatch] { guard !query.isEmpty, limit > 0 else { return [] } let loweredQuery = query.lowercased() let singleCharacterQuery = omnibarSingleCharacterQuery(for: query) let includeCurrentPanelForSingleCharacterQuery = singleCharacterQuery != nil let tabManager = AppDelegate.shared?.tabManager let currentPanelWorkspaceId = tabManager?.tabs.first(where: { tab in tab.panels[panel.id] is BrowserPanel })?.id var matches: [OmnibarOpenTabMatch] = [] var seenKeys = Set() func preferredPanelURL(_ browserPanel: BrowserPanel) -> String? { browserPanel.preferredURLStringForOmnibar() } func addMatch( tabId: UUID, panelId: UUID, url: String, title: String?, isKnownOpenTab: Bool, matches: inout [OmnibarOpenTabMatch], seenKeys: inout Set ) { let key = "\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())" guard !seenKeys.contains(key) else { return } seenKeys.insert(key) matches.append( OmnibarOpenTabMatch( tabId: tabId, panelId: panelId, url: url, title: title, isKnownOpenTab: isKnownOpenTab ) ) } if includeCurrentPanelForSingleCharacterQuery, let query = singleCharacterQuery, let currentURL = preferredPanelURL(panel), !currentURL.isEmpty { let rawTitle = panel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines) let title = rawTitle.isEmpty ? nil : rawTitle if omnibarHasSingleCharacterPrefixMatch(query: query, url: currentURL, title: title) { addMatch( tabId: currentPanelWorkspaceId ?? panel.workspaceId, panelId: panel.id, url: currentURL, title: title, isKnownOpenTab: currentPanelWorkspaceId != nil, matches: &matches, seenKeys: &seenKeys ) } } guard let tabManager else { return matches } for tab in tabManager.tabs { for (panelId, anyPanel) in tab.panels { guard let browserPanel = anyPanel as? BrowserPanel else { continue } guard let currentURL = preferredPanelURL(browserPanel), !currentURL.isEmpty else { continue } let isCurrentPanel = tab.id == panel.workspaceId && panelId == panel.id if isCurrentPanel && !includeCurrentPanelForSingleCharacterQuery { continue } let rawTitle = browserPanel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines) let title = rawTitle.isEmpty ? nil : rawTitle let isMatch: Bool = { if let singleCharacterQuery { return omnibarHasSingleCharacterPrefixMatch( query: singleCharacterQuery, url: currentURL, title: title ) } let haystacks = [ currentURL.lowercased(), (title ?? "").lowercased(), ] return haystacks.contains { $0.contains(loweredQuery) } }() guard isMatch else { continue } addMatch( tabId: tab.id, panelId: panelId, url: currentURL, title: title, isKnownOpenTab: true, matches: &matches, seenKeys: &seenKeys ) } } if matches.count <= limit { return matches } return Array(matches.prefix(limit)) } private func forcedRemoteSuggestionsForUITest() -> [String]? { let raw = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] ?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") guard let raw, let data = raw.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] else { return nil } let values = parsed.compactMap { item -> String? in guard let s = item as? String else { return nil } let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } return values.isEmpty ? nil : values } private func applyOmnibarEffects(_ effects: OmnibarEffects) { if effects.shouldRefreshSuggestions { refreshSuggestions() } if effects.shouldSelectAll { // Apply immediately for fast Cmd+L typing, then retry once in case // first responder wasn't fully settled on the same runloop. DispatchQueue.main.async { NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) } } if effects.shouldBlurToWebView { hideSuggestions() addressBarFocused = false DispatchQueue.main.async { guard isFocused else { return } guard let window = panel.webView.window, !panel.webView.isHiddenOrHasHiddenAncestor else { return } panel.clearWebViewFocusSuppression() window.makeFirstResponder(panel.webView) NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) } } } } enum OmnibarInputIntent: Equatable { case urlLike case queryLike case ambiguous } struct OmnibarOpenTabMatch: Equatable { let tabId: UUID let panelId: UUID let url: String let title: String? let isKnownOpenTab: Bool init(tabId: UUID, panelId: UUID, url: String, title: String?, isKnownOpenTab: Bool = true) { self.tabId = tabId self.panelId = panelId self.url = url self.title = title self.isKnownOpenTab = isKnownOpenTab } } func omnibarInputIntent(for query: String) -> OmnibarInputIntent { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .ambiguous } if resolveBrowserNavigableURL(trimmed) != nil { return .urlLike } if trimmed.contains(" ") { return .queryLike } if trimmed.contains(".") { return .ambiguous } return .queryLike } func omnibarSuggestionCompletion(for suggestion: OmnibarSuggestion) -> String? { switch suggestion.kind { case .navigate(let url): return url case .history(let url, _): return url case .switchToTab(_, _, let url, _): return url default: return nil } } func omnibarSuggestionTitle(for suggestion: OmnibarSuggestion) -> String? { switch suggestion.kind { case .history(_, let title): return title case .switchToTab(_, _, _, let title): return title default: return nil } } func omnibarSuggestionMatchesTypedPrefix( typedText: String, suggestionCompletion: String, suggestionTitle: String? = nil ) -> Bool { let trimmedQuery = typedText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedQuery.isEmpty else { return false } let query = trimmedQuery.lowercased() let trimmedCompletion = suggestionCompletion.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedCompletion.isEmpty else { return false } let loweredCompletion = trimmedCompletion.lowercased() let schemeStripped = stripHTTPSchemePrefix(trimmedCompletion) let schemeAndWWWStripped = stripHTTPSchemeAndWWWPrefix(trimmedCompletion) let typedIncludesScheme = query.hasPrefix("https://") || query.hasPrefix("http://") let typedIncludesWWWPrefix = query.hasPrefix("www.") if typedIncludesScheme, loweredCompletion.hasPrefix(query) { return true } if schemeStripped.hasPrefix(query) { return true } if !typedIncludesWWWPrefix && schemeAndWWWStripped.hasPrefix(query) { return true } let normalizedTitle = suggestionTitle? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() ?? "" if !normalizedTitle.isEmpty && normalizedTitle.hasPrefix(query) { return true } return false } func omnibarSuggestionSupportsAutocompletion(query: String, suggestion: OmnibarSuggestion) -> Bool { if case .search = suggestion.kind { return false } if case .remote = suggestion.kind { return false } guard let completion = omnibarSuggestionCompletion(for: suggestion) else { return false } // Reject URLs whose host lacks a TLD (e.g. "https://news." → host "news"). if let components = URLComponents(string: completion), let host = components.host?.lowercased() { let trimmedHost = host.hasSuffix(".") ? String(host.dropLast()) : host if !trimmedHost.contains(".") { return false } } let title = omnibarSuggestionTitle(for: suggestion) return omnibarSuggestionMatchesTypedPrefix( typedText: query, suggestionCompletion: completion, suggestionTitle: title ) } func omnibarSingleCharacterQuery(for query: String) -> String? { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard trimmed.utf16.count == 1 else { return nil } return trimmed } func omnibarStrippedURL(_ value: String) -> String { return stripHTTPSchemeAndWWWPrefix(value) } func omnibarScoringCandidate(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "" } if let components = URLComponents(string: trimmed), let host = components.host?.lowercased() { let hostWithoutWWW = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host let normalizedScheme = components.scheme?.lowercased() let isDefaultPort = (normalizedScheme == "http" && components.port == 80) || (normalizedScheme == "https" && components.port == 443) let portSuffix = { guard let port = components.port, !isDefaultPort else { return "" } return ":\(port)" }() var normalized = "\(hostWithoutWWW)\(portSuffix)" let path = components.percentEncodedPath if !path.isEmpty && path != "/" { normalized += path } else if path == "/" { normalized += "/" } if let query = components.percentEncodedQuery, !query.isEmpty { normalized += "?\(query)" } if let fragment = components.percentEncodedFragment, !fragment.isEmpty { normalized += "#\(fragment)" } return normalized } return stripHTTPSchemeAndWWWPrefix(trimmed) } func omnibarHasSingleCharacterPrefixMatch(query: String, url: String, title: String?) -> Bool { guard let trimmedQuery = omnibarSingleCharacterQuery(for: query) else { return false } let normalizedURL = omnibarStrippedURL(url).lowercased() let normalizedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" return normalizedURL.hasPrefix(trimmedQuery) || normalizedTitle.hasPrefix(trimmedQuery) } func buildOmnibarSuggestions( query: String, engineName: String, historyEntries: [BrowserHistoryStore.Entry], openTabMatches: [OmnibarOpenTabMatch] = [], remoteQueries: [String], resolvedURL: URL?, limit: Int = 8, now: Date = Date() ) -> [OmnibarSuggestion] { guard limit > 0 else { return [] } let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedQuery.isEmpty { return Array(historyEntries.prefix(limit).map { .history($0) }) } let singleCharacterQuery = omnibarSingleCharacterQuery(for: trimmedQuery) let isSingleCharacterQuery = singleCharacterQuery != nil let shouldIncludeRemoteSuggestions = !isSingleCharacterQuery let filteredHistoryEntries: [BrowserHistoryStore.Entry] let filteredOpenTabMatches: [OmnibarOpenTabMatch] if let singleCharacterQuery { filteredHistoryEntries = historyEntries.filter { omnibarHasSingleCharacterPrefixMatch(query: singleCharacterQuery, url: $0.url, title: $0.title) } filteredOpenTabMatches = openTabMatches.filter { omnibarHasSingleCharacterPrefixMatch(query: singleCharacterQuery, url: $0.url, title: $0.title) } } else { filteredHistoryEntries = historyEntries filteredOpenTabMatches = openTabMatches } let shouldSuppressSingleCharacterSearchResult = isSingleCharacterQuery && (!filteredHistoryEntries.isEmpty || !filteredOpenTabMatches.isEmpty) struct RankedSuggestion { let suggestion: OmnibarSuggestion let score: Double let order: Int let isAutocompletableMatch: Bool let kindPriority: Int } var bestByCompletion: [String: RankedSuggestion] = [:] var order = 0 let intent = omnibarInputIntent(for: trimmedQuery) let normalizedQuery = trimmedQuery.lowercased() func suggestionPriority(for kind: OmnibarSuggestion.Kind) -> Int { switch kind { case .search: return 300 case .remote: return 350 default: return 0 } } func completionScore(for candidate: String) -> Double { let c = candidate.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let q = normalizedQuery guard !c.isEmpty, !q.isEmpty else { return 0 } let scoringCandidate = omnibarScoringCandidate(c) if !scoringCandidate.isEmpty { if scoringCandidate == q { return 260 } if scoringCandidate.hasPrefix(q) { return 220 } if scoringCandidate.contains(q) { return 150 } } if c == q { return 240 } if c.hasPrefix(q) { return 170 } if c.contains(q) { return 95 } return 0 } func insert(_ suggestion: OmnibarSuggestion, score: Double) { let key = suggestion.completion.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !key.isEmpty else { return } let isAutocompletableMatch = omnibarSuggestionSupportsAutocompletion(query: trimmedQuery, suggestion: suggestion) let ranked = RankedSuggestion( suggestion: suggestion, score: score, order: order, isAutocompletableMatch: isAutocompletableMatch, kindPriority: suggestionPriority(for: suggestion.kind) ) order += 1 if let existing = bestByCompletion[key] { let shouldReplaceExisting: Bool = { // For identical completions, keep "go to URL" over "switch to tab" so // pressing Enter performs navigation unless the user explicitly picks a tab row. switch (existing.suggestion.kind, ranked.suggestion.kind) { case (.navigate, .switchToTab): return false case (.switchToTab, .navigate): return true default: return ranked.score > existing.score } }() if shouldReplaceExisting { bestByCompletion[key] = ranked } } else { bestByCompletion[key] = ranked } } if !(isSingleCharacterQuery && shouldSuppressSingleCharacterSearchResult) { let searchBaseScore: Double switch intent { case .queryLike: searchBaseScore = 820 case .ambiguous: searchBaseScore = 540 case .urlLike: searchBaseScore = 140 } insert(.search(engineName: engineName, query: trimmedQuery), score: searchBaseScore + completionScore(for: trimmedQuery)) } if let resolvedURL { let completion = resolvedURL.absoluteString let navigateBaseScore: Double switch intent { case .urlLike: navigateBaseScore = 1_020 case .ambiguous: navigateBaseScore = 760 case .queryLike: navigateBaseScore = 470 } insert(.navigate(url: completion), score: navigateBaseScore + completionScore(for: completion)) } for (index, entry) in filteredHistoryEntries.prefix(max(limit * 2, limit)).enumerated() { let intentBaseScore: Double switch intent { case .urlLike: intentBaseScore = 780 case .ambiguous: intentBaseScore = 690 case .queryLike: intentBaseScore = 600 } let urlMatch = completionScore(for: entry.url) let titleMatch = completionScore(for: entry.title ?? "") * 0.6 let ageHours = max(0, now.timeIntervalSince(entry.lastVisited) / 3600) let recencyScore = max(0, 75 - (ageHours / 5)) let visitScore = min(95, log1p(Double(max(1, entry.visitCount))) * 32) let typedScore = min(230, log1p(Double(max(0, entry.typedCount))) * 100) let typedRecencyScore: Double if let lastTypedAt = entry.lastTypedAt { let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600) typedRecencyScore = max(0, 80 - (typedAgeHours / 5)) } else { typedRecencyScore = 0 } let positionScore = Double(max(0, 16 - index)) let total = intentBaseScore + urlMatch + titleMatch + recencyScore + visitScore + typedScore + typedRecencyScore + positionScore insert(.history(entry), score: total) } for (index, match) in filteredOpenTabMatches.prefix(limit).enumerated() { let intentBaseScore: Double switch intent { case .urlLike: intentBaseScore = 1_180 case .ambiguous: intentBaseScore = 980 case .queryLike: intentBaseScore = 820 } let urlMatch = completionScore(for: match.url) let titleMatch = completionScore(for: match.title ?? "") * 0.65 let positionScore = Double(max(0, 14 - index)) * 0.9 let resolvedURLBonus: Double if let resolvedURL, resolvedURL.absoluteString.caseInsensitiveCompare(match.url) == .orderedSame { resolvedURLBonus = 120 } else { resolvedURLBonus = 0 } let total = intentBaseScore + urlMatch + titleMatch + positionScore + resolvedURLBonus if match.isKnownOpenTab { insert( .switchToTab(tabId: match.tabId, panelId: match.panelId, url: match.url, title: match.title), score: total ) } else { insert( OmnibarSuggestion.history(url: match.url, title: match.title), score: total ) } } if shouldIncludeRemoteSuggestions { for (index, remoteQuery) in remoteQueries.prefix(limit).enumerated() { let trimmedRemote = remoteQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedRemote.isEmpty else { continue } let remoteBaseScore: Double switch intent { case .queryLike: remoteBaseScore = 690 case .ambiguous: remoteBaseScore = 450 case .urlLike: remoteBaseScore = 110 } let positionScore = Double(max(0, 14 - index)) * 0.9 let total = remoteBaseScore + completionScore(for: trimmedRemote) + positionScore insert(.remoteSearchSuggestion(trimmedRemote), score: total) } } let sorted = bestByCompletion.values.sorted { lhs, rhs in if lhs.isAutocompletableMatch != rhs.isAutocompletableMatch { return lhs.isAutocompletableMatch } if lhs.score != rhs.score { return lhs.score > rhs.score } if lhs.kindPriority != rhs.kindPriority { return lhs.kindPriority < rhs.kindPriority } if lhs.order != rhs.order { return lhs.order < rhs.order } return lhs.suggestion.completion < rhs.suggestion.completion } let suggestions = Array(sorted.map(\.suggestion).prefix(limit)) return prioritizedAutocompletionSuggestions(suggestions: Array(suggestions), for: trimmedQuery) } private func prioritizedAutocompletionSuggestions(suggestions: [OmnibarSuggestion], for query: String) -> [OmnibarSuggestion] { guard let preferred = omnibarPreferredAutocompletionSuggestionIndex( suggestions: suggestions, query: query ) else { return suggestions } guard preferred != 0 else { return suggestions } var reordered = suggestions let suggestion = reordered.remove(at: preferred) reordered.insert(suggestion, at: 0) return reordered } private func omnibarPreferredAutocompletionSuggestionIndex( suggestions: [OmnibarSuggestion], query: String ) -> Int? { guard !query.isEmpty else { return nil } var candidates: [(idx: Int, suffixLength: Int)] = [] for (idx, suggestion) in suggestions.enumerated() { guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: suggestion) else { continue } guard let completion = omnibarSuggestionCompletion(for: suggestion) else { continue } let displayCompletion = omnibarSuggestionMatchesTypedPrefix( typedText: query, suggestionCompletion: completion, suggestionTitle: omnibarSuggestionTitle(for: suggestion) ) ? completion : "" guard !displayCompletion.isEmpty else { continue } let suffixLength = max( 0, omnibarSuggestionDisplayText(forPrefixing: displayCompletion, query: query).utf16.count - query.utf16.count ) candidates.append((idx: idx, suffixLength: suffixLength)) } guard let preferred = candidates.min(by: { if $0.suffixLength != $1.suffixLength { return $0.suffixLength < $1.suffixLength } return $0.idx < $1.idx })?.idx else { return nil } return preferred } private func omnibarSuggestionDisplayText(forPrefixing completion: String, query: String) -> String { let typedIncludesScheme = query.hasPrefix("https://") || query.hasPrefix("http://") let typedIncludesWWWPrefix = query.hasPrefix("www.") if typedIncludesScheme { return completion } if typedIncludesWWWPrefix { return stripHTTPSchemePrefix(completion) } return stripHTTPSchemeAndWWWPrefix(completion) } func staleOmnibarRemoteSuggestionsForDisplay( query: String, previousRemoteQuery: String, previousRemoteSuggestions: [String], limit: Int = 8 ) -> [String] { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedPreviousQuery = previousRemoteQuery.trimmingCharacters(in: .whitespacesAndNewlines) let loweredQuery = trimmedQuery.lowercased() let loweredPreviousQuery = trimmedPreviousQuery.lowercased() guard !trimmedQuery.isEmpty, !trimmedPreviousQuery.isEmpty else { return [] } guard loweredQuery == loweredPreviousQuery || loweredQuery.hasPrefix(loweredPreviousQuery) || loweredPreviousQuery.hasPrefix(loweredQuery) else { return [] } guard !previousRemoteSuggestions.isEmpty else { return [] } let sanitized = previousRemoteSuggestions.compactMap { raw -> String? in let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return trimmed } if sanitized.isEmpty { return [] } return Array(sanitized.prefix(limit)) } func omnibarInlineCompletionForDisplay( typedText: String, suggestions: [OmnibarSuggestion], isFocused: Bool, selectionRange: NSRange, hasMarkedText: Bool ) -> OmnibarInlineCompletion? { guard isFocused else { return nil } guard !hasMarkedText else { return nil } let query = typedText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return nil } let loweredQuery = query.lowercased() let typedIncludesScheme = loweredQuery.hasPrefix("https://") || loweredQuery.hasPrefix("http://") let typedIncludesWWWPrefix = loweredQuery.hasPrefix("www.") let queryCount = query.utf16.count let urlCandidate = suggestions.first { suggestion in guard let completion = omnibarSuggestionCompletion(for: suggestion) else { return false } return omnibarSuggestionMatchesTypedPrefix( typedText: query, suggestionCompletion: completion, suggestionTitle: omnibarSuggestionTitle(for: suggestion) ) } guard let candidate = urlCandidate else { return nil } let acceptedText = candidate.completion let displayText: String if typedQueryHasExplicitPathOrQuery(query) { if typedIncludesScheme { displayText = acceptedText } else if typedIncludesWWWPrefix { displayText = stripHTTPSchemePrefix(acceptedText) } else { displayText = stripHTTPSchemeAndWWWPrefix(acceptedText) } } else if let hostOnlyDisplay = inlineCompletionHostDisplayText( for: acceptedText, typedIncludesScheme: typedIncludesScheme, typedIncludesWWWPrefix: typedIncludesWWWPrefix ) { displayText = hostOnlyDisplay } else { if typedIncludesScheme { displayText = acceptedText } else if typedIncludesWWWPrefix { displayText = stripHTTPSchemePrefix(acceptedText) } else { displayText = stripHTTPSchemeAndWWWPrefix(acceptedText) } } guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: candidate) else { return nil } // The display text must start with the typed query so the inline completion // visually extends what the user typed rather than replacing it (e.g. a // history entry matched via title "localhost:3000" whose URL is google.com // should not replace a typed "l" with "g"). guard displayText.lowercased().hasPrefix(loweredQuery) else { return nil } guard displayText.utf16.count > queryCount else { return nil } let displayCount = displayText.utf16.count let resolvedSelectionRange: NSRange = { if selectionRange.location == NSNotFound { return NSRange(location: queryCount, length: 0) } let clampedLocation = min(selectionRange.location, displayCount) let remaining = max(0, displayCount - clampedLocation) let clampedLength = min(selectionRange.length, remaining) return NSRange(location: clampedLocation, length: clampedLength) }() let suffixRange = NSRange(location: queryCount, length: max(0, displayCount - queryCount)) let isCaretAtTypedBoundary = (resolvedSelectionRange.length == 0 && resolvedSelectionRange.location == queryCount) let isSuffixSelection = NSEqualRanges(resolvedSelectionRange, suffixRange) let isSelectAllSelection = (resolvedSelectionRange.location == 0 && resolvedSelectionRange.length == displayCount) // Command+A can briefly report just the typed prefix selection before the full // select-all range lands. Keep inline completion alive through that transition. let typedPrefixSelection = NSRange(location: 0, length: queryCount) let isTypedPrefixSelection = NSEqualRanges(resolvedSelectionRange, typedPrefixSelection) guard isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection else { return nil } return OmnibarInlineCompletion(typedText: query, displayText: displayText, acceptedText: acceptedText) } func omnibarDesiredSelectionRangeForInlineCompletion( currentSelection: NSRange, inlineCompletion: OmnibarInlineCompletion ) -> NSRange { let typedCount = inlineCompletion.typedText.utf16.count let typedPrefixSelection = NSRange(location: 0, length: typedCount) let displayCount = inlineCompletion.displayText.utf16.count let isSelectAll = currentSelection.location == 0 && currentSelection.length == displayCount if isSelectAll || NSEqualRanges(currentSelection, inlineCompletion.suffixRange) || NSEqualRanges(currentSelection, typedPrefixSelection) { return currentSelection } return inlineCompletion.suffixRange } func omnibarPublishedBufferTextForFieldChange( fieldValue: String, inlineCompletion: OmnibarInlineCompletion?, selectionRange: NSRange?, hasMarkedText: Bool ) -> String { guard !hasMarkedText else { return fieldValue } guard let inlineCompletion else { return fieldValue } guard fieldValue == inlineCompletion.displayText else { return fieldValue } guard let selectionRange else { return inlineCompletion.typedText } let typedCount = inlineCompletion.typedText.utf16.count let displayCount = inlineCompletion.displayText.utf16.count let typedPrefixSelection = NSRange(location: 0, length: typedCount) let isCaretAtTypedBoundary = selectionRange.location == typedCount && selectionRange.length == 0 let isSuffixSelection = NSEqualRanges(selectionRange, inlineCompletion.suffixRange) let isSelectAllSelection = selectionRange.location == 0 && selectionRange.length == displayCount let isTypedPrefixSelection = NSEqualRanges(selectionRange, typedPrefixSelection) if isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection { return inlineCompletion.typedText } return fieldValue } func omnibarInlineCompletionIfBufferMatchesTypedPrefix( bufferText: String, inlineCompletion: OmnibarInlineCompletion? ) -> OmnibarInlineCompletion? { guard let inlineCompletion else { return nil } guard bufferText == inlineCompletion.typedText else { return nil } return inlineCompletion } private func typedQueryHasExplicitPathOrQuery(_ typedQuery: String) -> Bool { var normalized = typedQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized.hasPrefix("https://") { normalized.removeFirst("https://".count) } else if normalized.hasPrefix("http://") { normalized.removeFirst("http://".count) } return normalized.contains("/") || normalized.contains("?") || normalized.contains("#") } private func inlineCompletionHostDisplayText( for acceptedText: String, typedIncludesScheme: Bool, typedIncludesWWWPrefix: Bool ) -> String? { guard let components = URLComponents(string: acceptedText), var host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty else { return nil } if !typedIncludesWWWPrefix, host.hasPrefix("www.") { host.removeFirst("www.".count) } let portSuffix: String if let port = components.port { let scheme = components.scheme?.lowercased() let isDefaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80) portSuffix = isDefaultPort ? "" : ":\(port)" } else { portSuffix = "" } let hostWithPort = "\(host)\(portSuffix)" if typedIncludesScheme { let scheme = (components.scheme?.lowercased() == "http") ? "http" : "https" return "\(scheme)://\(hostWithPort)" } return hostWithPort } private func stripHTTPSchemePrefix(_ raw: String) -> String { var normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized.hasPrefix("https://") { normalized.removeFirst("https://".count) } else if normalized.hasPrefix("http://") { normalized.removeFirst("http://".count) } return normalized } private func stripHTTPSchemeAndWWWPrefix(_ raw: String) -> String { var normalized = stripHTTPSchemePrefix(raw) if normalized.hasPrefix("www.") { normalized.removeFirst("www.".count) } return normalized } private struct OmnibarPillFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { let next = nextValue() if next != .zero { value = next } } } // MARK: - Omnibar State Machine struct OmnibarState: Equatable { var isFocused: Bool = false var currentURLString: String = "" var buffer: String = "" var suggestions: [OmnibarSuggestion] = [] var selectedSuggestionIndex: Int = 0 var selectedSuggestionID: String? var isUserEditing: Bool = false } enum OmnibarEvent: Equatable { case focusGained(currentURLString: String) case focusLostRevertBuffer(currentURLString: String) case focusLostPreserveBuffer(currentURLString: String) case panelURLChanged(currentURLString: String) case bufferChanged(String) case suggestionsUpdated([OmnibarSuggestion]) case moveSelection(delta: Int) case highlightIndex(Int) case escape } struct OmnibarEffects: Equatable { var shouldSelectAll: Bool = false var shouldBlurToWebView: Bool = false var shouldRefreshSuggestions: Bool = false } @discardableResult func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEffects { var effects = OmnibarEffects() switch event { case .focusGained(let url): state.isFocused = true state.currentURLString = url state.buffer = url state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil effects.shouldSelectAll = true case .focusLostRevertBuffer(let url): state.isFocused = false state.currentURLString = url state.buffer = url state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil case .focusLostPreserveBuffer(let url): state.isFocused = false state.currentURLString = url state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil case .panelURLChanged(let url): state.currentURLString = url if !state.isUserEditing { state.buffer = url state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil } case .bufferChanged(let newValue): state.buffer = newValue if state.isFocused { state.isUserEditing = (newValue != state.currentURLString) state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil effects.shouldRefreshSuggestions = true } case .suggestionsUpdated(let items): let previousItems = state.suggestions let previousSelectedID = state.selectedSuggestionID state.suggestions = items if items.isEmpty { state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil } else if let previousSelectedID, let existingIdx = items.firstIndex(where: { $0.id == previousSelectedID }) { state.selectedSuggestionIndex = existingIdx state.selectedSuggestionID = items[existingIdx].id } else if let preferredSuggestionIndex = omnibarPreferredAutocompletionSuggestionIndex( suggestions: items, query: state.buffer ) { state.selectedSuggestionIndex = preferredSuggestionIndex state.selectedSuggestionID = items[preferredSuggestionIndex].id } else if previousItems.isEmpty { // Popup reopened: start keyboard focus from the first row. state.selectedSuggestionIndex = 0 state.selectedSuggestionID = items[0].id } else if let previousSelectedID, let idx = items.firstIndex(where: { $0.id == previousSelectedID }) { state.selectedSuggestionIndex = idx state.selectedSuggestionID = items[idx].id } else { state.selectedSuggestionIndex = min(max(0, state.selectedSuggestionIndex), items.count - 1) state.selectedSuggestionID = items[state.selectedSuggestionIndex].id } case .moveSelection(let delta): guard !state.suggestions.isEmpty else { break } state.selectedSuggestionIndex = min( max(0, state.selectedSuggestionIndex + delta), state.suggestions.count - 1 ) state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id case .highlightIndex(let idx): guard !state.suggestions.isEmpty else { break } state.selectedSuggestionIndex = min(max(0, idx), state.suggestions.count - 1) state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id case .escape: guard state.isFocused else { break } // Chrome semantics: // - If user input is in progress OR the popup is open: revert to the page URL and select-all. // - Otherwise: exit omnibar focus. if state.isUserEditing || !state.suggestions.isEmpty { state.isUserEditing = false state.buffer = state.currentURLString state.suggestions = [] state.selectedSuggestionIndex = 0 state.selectedSuggestionID = nil effects.shouldSelectAll = true } else { effects.shouldBlurToWebView = true } } return effects } struct OmnibarSuggestion: Identifiable, Hashable { enum Kind: Hashable { case search(engineName: String, query: String) case navigate(url: String) case history(url: String, title: String?) case switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?) case remote(query: String) } let kind: Kind // Stable identity prevents row teardown/rebuild flicker while typing. var id: String { switch kind { case .search(let engineName, let query): return "search|\(engineName.lowercased())|\(query.lowercased())" case .navigate(let url): return "navigate|\(url.lowercased())" case .history(let url, _): return "history|\(url.lowercased())" case .switchToTab(let tabId, let panelId, let url, _): return "switch-tab|\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())" case .remote(let query): return "remote|\(query.lowercased())" } } var completion: String { switch kind { case .search(_, let q): return q case .navigate(let url): return url case .history(let url, _): return url case .switchToTab(_, _, let url, _): return url case .remote(let q): return q } } var primaryText: String { switch kind { case .search(let engineName, let q): return "Search \(engineName) for \"\(q)\"" case .navigate(let url): return Self.displayURLText(for: url) case .history(let url, let title): return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? Self.singleLineText(title) : Self.displayURLText(for: url) case .switchToTab(_, _, let url, let title): return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? Self.singleLineText(title) : Self.displayURLText(for: url) case .remote(let q): return q } } var listText: String { switch kind { case .history(let url, let title), .switchToTab(_, _, let url, let title): let titleOneline = Self.singleLineText(title) guard !titleOneline.isEmpty else { return Self.displayURLText(for: url) } return "\(titleOneline) — \(Self.displayURLText(for: url))" default: return primaryText } } var secondaryText: String? { switch kind { case .history(let url, let title): let titleOneline = Self.singleLineText(title) return titleOneline.isEmpty ? nil : Self.displayURLText(for: url) case .switchToTab(_, _, let url, let title): let titleOneline = Self.singleLineText(title) return titleOneline.isEmpty ? nil : Self.displayURLText(for: url) default: return nil } } var trailingBadgeText: String? { switch kind { case .switchToTab: return "Switch to tab" default: return nil } } var isHistoryRemovable: Bool { if case .history = kind { return true } return false } static func history(_ entry: BrowserHistoryStore.Entry) -> OmnibarSuggestion { OmnibarSuggestion(kind: .history(url: entry.url, title: entry.title)) } static func history(url: String, title: String?) -> OmnibarSuggestion { OmnibarSuggestion(kind: .history(url: url, title: title)) } static func search(engineName: String, query: String) -> OmnibarSuggestion { OmnibarSuggestion(kind: .search(engineName: engineName, query: query)) } static func navigate(url: String) -> OmnibarSuggestion { OmnibarSuggestion(kind: .navigate(url: url)) } static func switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?) -> OmnibarSuggestion { OmnibarSuggestion(kind: .switchToTab(tabId: tabId, panelId: panelId, url: url, title: title)) } private static func singleLineText(_ value: String?) -> String { var normalized = (value ?? "").replacingOccurrences(of: "\r", with: " ") .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\t", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) while normalized.contains(" ") { let collapsed = normalized.replacingOccurrences(of: " ", with: " ") if collapsed == normalized { break } normalized = collapsed } return normalized } static func remoteSearchSuggestion(_ query: String) -> OmnibarSuggestion { OmnibarSuggestion(kind: .remote(query: query)) } private static func displayURLText(for rawURL: String) -> String { guard let components = URLComponents(string: rawURL), var host = components.host else { return rawURL } if host.hasPrefix("www.") { host.removeFirst(4) } host = host.lowercased() var result = host if let port = components.port { result += ":\(port)" } let path = components.percentEncodedPath if !path.isEmpty, path != "/" { result += path } else if path == "/" { result += "/" } if let query = components.percentEncodedQuery, !query.isEmpty { result += "?\(query)" } if result.isEmpty { return rawURL } return result } } func browserOmnibarShouldReacquireFocusAfterEndEditing( suppressWebViewFocus: Bool, nextResponderIsOtherTextField: Bool ) -> Bool { suppressWebViewFocus && !nextResponderIsOtherTextField } private final class OmnibarNativeTextField: NSTextField { var onPointerDown: (() -> Void)? var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) isBordered = false isBezeled = false drawsBackground = false focusRingType = .none lineBreakMode = .byTruncatingTail usesSingleLineMode = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func mouseDown(with event: NSEvent) { #if DEBUG dlog("browser.omnibarClick") #endif onPointerDown?() if currentEditor() == nil { // First click — activate editing and select all (standard URL bar behavior). // Avoids NSTextView's tracking loop which can spin forever if text layout // enters an infinite invalidation cycle (e.g. under memory pressure). window?.makeFirstResponder(self) currentEditor()?.selectAll(nil) } else { // Already editing — allow normal click-to-place-cursor and drag-to-select. // Guard against a stuck tracking loop by posting a synthetic mouseUp after // a timeout. IMPORTANT: must use a background queue because super.mouseDown // blocks the main thread in NSTextView's tracking loop, so // DispatchQueue.main.asyncAfter would never fire. let cancelled = DispatchWorkItem { /* sentinel */ } let windowNumber = window?.windowNumber ?? 0 let location = event.locationInWindow DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + 3.0) { guard !cancelled.isCancelled else { return } if let fakeUp = NSEvent.mouseEvent( with: .leftMouseUp, location: location, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 0.0 ) { NSApp.postEvent(fakeUp, atStart: true) } } super.mouseDown(with: event) cancelled.cancel() } } override func keyDown(with event: NSEvent) { if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { return } super.keyDown(with: event) } override func performKeyEquivalent(with event: NSEvent) -> Bool { if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { return true } return super.performKeyEquivalent(with: event) } } private struct OmnibarTextFieldRepresentable: NSViewRepresentable { @Binding var text: String @Binding var isFocused: Bool let inlineCompletion: OmnibarInlineCompletion? let placeholder: String let onTap: () -> Void let onSubmit: () -> Void let onEscape: () -> Void let onFieldLostFocus: () -> Void let onMoveSelection: (Int) -> Void let onDeleteSelectedSuggestion: () -> Void let onAcceptInlineCompletion: () -> Void let onDeleteBackwardWithInlineSelection: () -> Void let onSelectionChanged: (NSRange, Bool) -> Void let shouldSuppressWebViewFocus: () -> Bool final class Coordinator: NSObject, NSTextFieldDelegate { var parent: OmnibarTextFieldRepresentable var isProgrammaticMutation: Bool = false var selectionObserver: NSObjectProtocol? weak var observedEditor: NSTextView? var appliedInlineCompletion: OmnibarInlineCompletion? var lastPublishedSelection: NSRange = NSRange(location: NSNotFound, length: 0) var lastPublishedHasMarkedText: Bool = false /// Guards against infinite focus loops: `true` = focus requested, `false` = blur requested, `nil` = idle. var pendingFocusRequest: Bool? init(parent: OmnibarTextFieldRepresentable) { self.parent = parent } deinit { if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) } } private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool { guard let window, let field = parentField else { return false } let responder = window.firstResponder if let editor = responder as? NSTextView, let delegateField = editor.delegate as? NSTextField { return delegateField !== field } if let textField = responder as? NSTextField { return textField !== field } return false } private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { return browserOmnibarShouldReacquireFocusAfterEndEditing( suppressWebViewFocus: parent.shouldSuppressWebViewFocus(), nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) ) } func controlTextDidBeginEditing(_ obj: Notification) { if !parent.isFocused { DispatchQueue.main.async { self.parent.isFocused = true } } attachSelectionObserverIfNeeded() publishSelectionState() } func controlTextDidEndEditing(_ obj: Notification) { if parent.isFocused { if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { guard pendingFocusRequest != true else { return } pendingFocusRequest = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingFocusRequest = nil guard self.parent.isFocused else { return } guard let field = self.parentField, let window = field.window else { return } guard self.shouldReacquireFocusAfterEndEditing(window: window) else { self.parent.onFieldLostFocus() return } // Check both the field itself AND its field editor (which becomes // the actual first responder when the text field is being edited). let fr = window.firstResponder let isAlreadyFocused = fr === field || field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field if !isAlreadyFocused { window.makeFirstResponder(field) } } return } parent.onFieldLostFocus() } detachSelectionObserver() } func controlTextDidChange(_ obj: Notification) { guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } let editor = field.currentEditor() as? NSTextView parent.text = omnibarPublishedBufferTextForFieldChange( fieldValue: field.stringValue, inlineCompletion: parent.inlineCompletion, selectionRange: editor?.selectedRange(), hasMarkedText: editor?.hasMarkedText() ?? false ) publishSelectionState() } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { switch commandSelector { case #selector(NSResponder.moveDown(_:)): parent.onMoveSelection(+1) return true case #selector(NSResponder.moveUp(_:)): parent.onMoveSelection(-1) return true case #selector(NSResponder.insertNewline(_:)): let currentFlags = NSApp.currentEvent?.modifierFlags ?? [] guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false } parent.onSubmit() return true case #selector(NSResponder.cancelOperation(_:)): parent.onEscape() return true case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() return true } return false case #selector(NSResponder.insertTab(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() return true } return false case #selector(NSResponder.deleteBackward(_:)): if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) { parent.onDeleteBackwardWithInlineSelection() return true } return false default: return false } } func attachSelectionObserverIfNeeded() { guard selectionObserver == nil else { return } guard let field = parentField else { return } guard let editor = field.currentEditor() as? NSTextView else { return } observedEditor = editor selectionObserver = NotificationCenter.default.addObserver( forName: NSTextView.didChangeSelectionNotification, object: editor, queue: .main ) { [weak self] _ in self?.publishSelectionState() } } func detachSelectionObserver() { if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) self.selectionObserver = nil } observedEditor = nil } weak var parentField: OmnibarNativeTextField? func publishSelectionState() { guard let field = parentField else { return } if let editor = field.currentEditor() as? NSTextView { let range = editor.selectedRange() let hasMarkedText = editor.hasMarkedText() guard !NSEqualRanges(range, lastPublishedSelection) || hasMarkedText != lastPublishedHasMarkedText else { return } lastPublishedSelection = range lastPublishedHasMarkedText = hasMarkedText parent.onSelectionChanged(range, hasMarkedText) } else { let location = field.stringValue.utf16.count let range = NSRange(location: location, length: 0) guard !NSEqualRanges(range, lastPublishedSelection) || lastPublishedHasMarkedText else { return } lastPublishedSelection = range lastPublishedHasMarkedText = false parent.onSelectionChanged(range, false) } } private func suffixSelectionMatchesInline(_ editor: NSTextView?, inline: OmnibarInlineCompletion?) -> Bool { guard let editor, let inline else { return false } let selected = editor.selectedRange() return NSEqualRanges(selected, inline.suffixRange) } private func selectionIsTypedPrefixBoundary(_ editor: NSTextView?, inline: OmnibarInlineCompletion?) -> Bool { guard let editor, let inline else { return false } let selected = editor.selectedRange() let typedCount = inline.typedText.utf16.count return selected.location == typedCount && selected.length == 0 } func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool { let keyCode = event.keyCode let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function]) let lowered = event.charactersIgnoringModifiers?.lowercased() ?? "" let hasCommandOrControl = modifiers.contains(.command) || modifiers.contains(.control) // Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held. if hasCommandOrControl, lowered == "n" { parent.onMoveSelection(+1) return true } if hasCommandOrControl, lowered == "p" { parent.onMoveSelection(-1) return true } // Shift+Delete removes the selected history suggestion when possible. if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) { parent.onDeleteSelectedSuggestion() return true } switch keyCode { case 36, 76: // Return / keypad Enter guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false } parent.onSubmit() return true case 53: // Escape parent.onEscape() return true case 125: // Down parent.onMoveSelection(+1) return true case 126: // Up parent.onMoveSelection(-1) return true case 124, 119: // Right arrow / End if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() return true } case 48: // Tab if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() return true } case 51: // Backspace if let inline = parent.inlineCompletion, (suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) { parent.onDeleteBackwardWithInlineSelection() return true } default: break } return false } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeNSView(context: Context) -> OmnibarNativeTextField { let field = OmnibarNativeTextField(frame: .zero) field.font = .systemFont(ofSize: 12) field.placeholderString = placeholder field.delegate = context.coordinator field.target = nil field.action = nil field.isEditable = true field.isSelectable = true field.isEnabled = true field.stringValue = text field.onPointerDown = { onTap() } field.onHandleKeyEvent = { [weak coordinator = context.coordinator] event, editor in coordinator?.handleKeyEvent(event, editor: editor) ?? false } context.coordinator.parentField = field return field } func updateNSView(_ nsView: OmnibarNativeTextField, context: Context) { context.coordinator.parent = self context.coordinator.parentField = nsView nsView.placeholderString = placeholder let activeInlineCompletion = omnibarInlineCompletionIfBufferMatchesTypedPrefix( bufferText: text, inlineCompletion: inlineCompletion ) let desiredDisplayText = activeInlineCompletion?.displayText ?? text if let editor = nsView.currentEditor() as? NSTextView { if editor.string != desiredDisplayText { context.coordinator.isProgrammaticMutation = true editor.string = desiredDisplayText nsView.stringValue = desiredDisplayText context.coordinator.isProgrammaticMutation = false } } else if nsView.stringValue != desiredDisplayText { nsView.stringValue = desiredDisplayText } if let window = nsView.window { let firstResponder = window.firstResponder let isFirstResponder = firstResponder === nsView || nsView.currentEditor() != nil || ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { // Defer to avoid triggering input method XPC during layout pass, // which can crash via re-entrant view hierarchy modification. context.coordinator.pendingFocusRequest = true DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } let fr = window.firstResponder let alreadyFocused = fr === nsView || nsView.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard !alreadyFocused else { return } window.makeFirstResponder(nsView) } } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false { context.coordinator.pendingFocusRequest = false DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } let fr = window.firstResponder let stillFirst = fr === nsView || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard stillFirst else { return } window.makeFirstResponder(nil) } } } if let editor = nsView.currentEditor() as? NSTextView { if let activeInlineCompletion { let currentSelection = editor.selectedRange() let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion( currentSelection: currentSelection, inlineCompletion: activeInlineCompletion ) if context.coordinator.appliedInlineCompletion != activeInlineCompletion || !NSEqualRanges(currentSelection, desiredSelection) { context.coordinator.isProgrammaticMutation = true editor.setSelectedRange(desiredSelection) context.coordinator.isProgrammaticMutation = false } } else if context.coordinator.appliedInlineCompletion != nil { let end = text.utf16.count let current = editor.selectedRange() if current.length != 0 || current.location != end { context.coordinator.isProgrammaticMutation = true editor.setSelectedRange(NSRange(location: end, length: 0)) context.coordinator.isProgrammaticMutation = false } } } context.coordinator.appliedInlineCompletion = activeInlineCompletion context.coordinator.attachSelectionObserverIfNeeded() context.coordinator.publishSelectionState() } static func dismantleNSView(_ nsView: OmnibarNativeTextField, coordinator: Coordinator) { nsView.onPointerDown = nil nsView.onHandleKeyEvent = nil nsView.delegate = nil coordinator.detachSelectionObserver() coordinator.parentField = nil } } private struct OmnibarSuggestionsView: View { let engineName: String let items: [OmnibarSuggestion] let selectedIndex: Int let isLoadingRemoteSuggestions: Bool let searchSuggestionsEnabled: Bool let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void @Environment(\.colorScheme) private var colorScheme // Keep radii below half of the smallest rendered heights so this keeps a // squircle silhouette instead of auto-clamping into a capsule. private let popupCornerRadius: CGFloat = 12 private let rowHighlightCornerRadius: CGFloat = 9 private let singleLineRowHeight: CGFloat = 24 private let rowSpacing: CGFloat = 1 private let topInset: CGFloat = 3 private let bottomInset: CGFloat = 3 private var horizontalInset: CGFloat { topInset } private let maxPopupHeight: CGFloat = 560 private var totalRowCount: Int { max(1, items.count) } private func rowHeight(for item: OmnibarSuggestion) -> CGFloat { return singleLineRowHeight } private var contentHeight: CGFloat { let rowsHeight = items.isEmpty ? singleLineRowHeight : items.reduce(CGFloat(0)) { partial, item in partial + rowHeight(for: item) } let gaps = CGFloat(max(0, totalRowCount - 1)) return rowsHeight + (gaps * rowSpacing) + topInset + bottomInset } private var minimumPopupHeight: CGFloat { singleLineRowHeight + topInset + bottomInset } private func snapToDevicePixels(_ value: CGFloat) -> CGFloat { let scale = NSScreen.main?.backingScaleFactor ?? 2 return (value * scale).rounded(.toNearestOrAwayFromZero) / scale } private var popupHeight: CGFloat { snapToDevicePixels(min(max(contentHeight, minimumPopupHeight), maxPopupHeight)) } private var isPointerDrivenSelectionEvent: Bool { guard let event = NSApp.currentEvent else { return false } switch event.type { case .mouseMoved, .leftMouseDown, .leftMouseDragged, .leftMouseUp, .rightMouseDown, .rightMouseDragged, .rightMouseUp, .otherMouseDown, .otherMouseDragged, .otherMouseUp, .scrollWheel: return true default: return false } } private var shouldScroll: Bool { contentHeight > maxPopupHeight } private var listTextColor: Color { switch colorScheme { case .light: return Color(nsColor: .labelColor) case .dark: return Color.white.opacity(0.9) @unknown default: return Color(nsColor: .labelColor) } } private var badgeTextColor: Color { switch colorScheme { case .light: return Color(nsColor: .secondaryLabelColor) case .dark: return Color.white.opacity(0.72) @unknown default: return Color(nsColor: .secondaryLabelColor) } } private var badgeBackgroundColor: Color { switch colorScheme { case .light: return Color.black.opacity(0.06) case .dark: return Color.white.opacity(0.08) @unknown default: return Color.black.opacity(0.06) } } private var rowHighlightColor: Color { switch colorScheme { case .light: return Color.black.opacity(0.07) case .dark: return Color.white.opacity(0.12) @unknown default: return Color.black.opacity(0.07) } } private var popupOverlayGradientColors: [Color] { switch colorScheme { case .light: return [ Color.white.opacity(0.55), Color.white.opacity(0.2), ] case .dark: return [ Color.black.opacity(0.26), Color.black.opacity(0.14), ] @unknown default: return [ Color.white.opacity(0.55), Color.white.opacity(0.2), ] } } private var popupBorderGradientColors: [Color] { switch colorScheme { case .light: return [ Color.white.opacity(0.65), Color.black.opacity(0.12), ] case .dark: return [ Color.white.opacity(0.22), Color.white.opacity(0.06), ] @unknown default: return [ Color.white.opacity(0.65), Color.black.opacity(0.12), ] } } private var popupShadowColor: Color { switch colorScheme { case .light: return Color.black.opacity(0.18) case .dark: return Color.black.opacity(0.45) @unknown default: return Color.black.opacity(0.18) } } @ViewBuilder private var rowsView: some View { VStack(spacing: rowSpacing) { ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in Button { #if DEBUG dlog("browser.suggestionClick index=\(idx) text=\"\(item.listText)\"") #endif onCommit(item) } label: { HStack(spacing: 6) { Text(item.listText) .font(.system(size: 11)) .foregroundStyle(listTextColor) .lineLimit(1) .truncationMode(.tail) if let badge = item.trailingBadgeText { Text(badge) .font(.system(size: 9.5, weight: .medium)) .foregroundStyle(badgeTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(badgeBackgroundColor) ) } Spacer(minLength: 0) } .padding(.horizontal, 8) .frame( maxWidth: .infinity, minHeight: rowHeight(for: item), maxHeight: rowHeight(for: item), alignment: .leading ) .background( RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex ? rowHighlightColor : Color.clear ) ) } .buttonStyle(.plain) .accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)") .accessibilityValue( idx == selectedIndex ? "selected \(item.listText)" : item.listText ) .onHover { hovering in if hovering, idx != selectedIndex, isPointerDrivenSelectionEvent { onHighlight(idx) } } .animation(.none, value: selectedIndex) } } .padding(.horizontal, horizontalInset) .padding(.top, topInset) .padding(.bottom, bottomInset) .frame(maxWidth: .infinity, alignment: .topLeading) } var body: some View { Group { if shouldScroll { ScrollView { rowsView } } else { rowsView } } .frame(height: popupHeight, alignment: .top) .overlay(alignment: .topTrailing) { if searchSuggestionsEnabled, isLoadingRemoteSuggestions { ProgressView() .controlSize(.small) .padding(.top, 7) .padding(.trailing, 14) .opacity(0.75) .allowsHitTesting(false) } } .background( RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .fill( LinearGradient( colors: popupOverlayGradientColors, startPoint: .top, endPoint: .bottom ) ) ) ) .overlay( RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .stroke( LinearGradient( colors: popupBorderGradientColors, startPoint: .top, endPoint: .bottom ), lineWidth: 1 ) ) .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .shadow(color: popupShadowColor, radius: 20, y: 10) .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityIdentifier("BrowserOmnibarSuggestions") .accessibilityLabel("Address bar suggestions") } } /// NSViewRepresentable wrapper for WKWebView struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool let portalZPriority: Int final class Coordinator { weak var panel: BrowserPanel? weak var webView: WKWebView? var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 var usesWindowPortal: Bool = false var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? } private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() onGeometryChanged?() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() onGeometryChanged?() } override func layout() { super.layout() onGeometryChanged?() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) onGeometryChanged?() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) onGeometryChanged?() } override func hitTest(_ point: NSPoint) -> NSView? { if shouldPassThroughToSidebarResizer(at: point) { return nil } return super.hitTest(point) } private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { // Pass through a narrow leading-edge band so the shared sidebar divider // handle can receive hover/click even when WKWebView is attached here. // Keeping this deterministic avoids flicker from dynamic left-edge scans. guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else { return false } guard let window, let contentView = window.contentView else { return false } let hostRectInContent = contentView.convert(bounds, from: self) return hostRectInContent.minX > 1 } } #if DEBUG private static func logDevToolsState( _ panel: BrowserPanel, event: String, generation: Int, retryCount: Int, details: String? = nil ) { var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" if let details, !details.isEmpty { line += " \(details)" } dlog(line) } private static func objectID(_ object: AnyObject?) -> String { guard let object else { return "nil" } return String(describing: Unmanaged.passUnretained(object).toOpaque()) } private static func responderDescription(_ responder: NSResponder?) -> String { guard let responder else { return "nil" } return "\(type(of: responder))@\(objectID(responder))" } private static func rectDescription(_ rect: NSRect) -> String { String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) } private static func attachContext(webView: WKWebView, host: NSView) -> String { let hostWindow = host.window?.windowNumber ?? -1 let webWindow = webView.window?.windowNumber ?? -1 let firstResponder = (webView.window ?? host.window)?.firstResponder return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" } #endif private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { var r = start var hops = 0 while let cur = r, hops < 64 { if cur === target { return true } r = cur.nextResponder hops += 1 } return false } private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { guard let responder else { return false } let responderType = String(describing: type(of: responder)) if responderType.contains("WKInspector") { return true } guard let view = responder as? NSView else { return false } var node: NSView? = view var hops = 0 while let current = node, hops < 64 { if String(describing: type(of: current)).contains("WKInspector") { return true } node = current.superview hops += 1 } return false } private static func firstResponderResignState( _ responder: NSResponder?, webView: WKWebView ) -> (needsResign: Bool, flags: String) { let inWebViewChain = responderChainContains(responder, target: webView) let inspectorResponder = isLikelyInspectorResponder(responder) let needsResign = inWebViewChain || inspectorResponder return ( needsResign: needsResign, flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" ) } func makeCoordinator() -> Coordinator { let coordinator = Coordinator() coordinator.panel = panel return coordinator } func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = true return container } private static func clearPortalCallbacks(for host: NSView) { guard let host = host as? HostContainerView else { return } host.onDidMoveToWindow = nil host.onGeometryChanged = nil } private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { guard let host = nsView as? HostContainerView else { return } let coordinator = context.coordinator let previousVisible = coordinator.desiredPortalVisibleInUI let previousZPriority = coordinator.desiredPortalZPriority coordinator.desiredPortalVisibleInUI = shouldAttachWebView coordinator.desiredPortalZPriority = portalZPriority coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in guard let host, let webView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard host.window != nil else { return } BrowserWindowPortalRegistry.bind( webView: webView, to: host, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = ObjectIdentifier(host) } host.onGeometryChanged = { [weak host, weak coordinator] in guard let host, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } BrowserWindowPortalRegistry.synchronizeForAnchor(host) } if !shouldAttachWebView { // In portal mode we no longer detach/re-attach to preserve DevTools state. // Sync the inspector preference directly so manual closes are respected. panel.syncDeveloperToolsPreferenceFromInspector() } if host.window != nil { let hostId = ObjectIdentifier(host) let shouldBindNow = coordinator.lastPortalHostId != hostId || webView.superview == nil || previousVisible != shouldAttachWebView || previousZPriority != portalZPriority if shouldBindNow { BrowserWindowPortalRegistry.bind( webView: webView, to: host, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(host) } else { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep // the previous anchor visible while this host is temporarily off-window. BrowserWindowPortalRegistry.updateEntryVisibility( for: webView, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) } panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG Self.logDevToolsState( panel, event: "portal.update", generation: coordinator.attachGeneration, retryCount: coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: host) ) #endif } private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. if let window = webView.window { let state = firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { window.makeFirstResponder(nil) } } // The target host can already be in-window while the source host is tearing down. // Re-check against the target window too (it can differ during split churn). if let window = host.window { let state = firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { window.makeFirstResponder(nil) } } // Detach from any previous host (bonsplit/SwiftUI may rearrange views). webView.removeFromSuperview() host.subviews.forEach { $0.removeFromSuperview() } host.addSubview(webView) // Work around WebKit bug 272474 where Inspect Element can render blank/flicker // when WKWebView is edge-pinned using Auto Layout constraints. webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] webView.frame = host.bounds // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. webView.needsLayout = true webView.layoutSubtreeIfNeeded() webView.needsDisplay = true webView.displayIfNeeded() } private static func scheduleAttachRetry( _ webView: WKWebView, panel: BrowserPanel, to host: NSView, coordinator: Coordinator, generation: Int ) { let retryInterval: TimeInterval = 1.0 / 60.0 // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } let work = DispatchWorkItem { [weak host, weak webView] in coordinator.attachRetryWorkItem = nil guard let host, let webView else { return } guard coordinator.attachGeneration == generation else { return } // If already attached, we're done. if webView.superview === host { coordinator.attachRetryCount = 0 return } // Wait until the host is actually in a window. SwiftUI can create a new container before it // is in a window during bonsplit tree updates; moving the webview too early can be flaky. guard host.window != nil else { coordinator.attachRetryCount += 1 #if DEBUG if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 { logDevToolsState( panel, event: "retry.waitingForWindow", generation: generation, retryCount: coordinator.attachRetryCount, details: attachContext(webView: webView, host: host) ) } #endif // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { scheduleAttachRetry( webView, panel: panel, to: host, coordinator: coordinator, generation: generation ) } } return } coordinator.attachRetryCount = 0 #if DEBUG logDevToolsState( panel, event: "retry.attach.begin", generation: generation, retryCount: 0, details: attachContext(webView: webView, host: host) ) #endif attachWebView(webView, to: host) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG logDevToolsState( panel, event: "retry.attached", generation: generation, retryCount: 0, details: attachContext(webView: webView, host: host) ) #endif } coordinator.attachRetryWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) } func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView context.coordinator.panel = panel context.coordinator.webView = webView Self.applyWebViewFirstResponderPolicy( panel: panel, webView: webView, isPanelFocused: isPanelFocused ) let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() if shouldUseWindowPortal { context.coordinator.usesWindowPortal = true Self.clearPortalCallbacks(for: nsView) updateUsingWindowPortal(nsView, context: context, webView: webView) Self.applyFocus( panel: panel, webView: webView, nsView: nsView, shouldFocusWebView: shouldFocusWebView, isPanelFocused: isPanelFocused ) return } if context.coordinator.usesWindowPortal { BrowserWindowPortalRegistry.detach(webView: webView) context.coordinator.usesWindowPortal = false context.coordinator.lastPortalHostId = nil } Self.clearPortalCallbacks(for: nsView) // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { // Split/layout churn can briefly create an off-window phase while DevTools is open. // Detaching here can blank inspector content even when visibility preference stays true. if nsView.window == nil, webView.superview != nil, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { #if DEBUG Self.logDevToolsState( panel, event: "detach.skipped.offWindowDevTools", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif return } #if DEBUG Self.logDevToolsState( panel, event: "detach.beforeSync", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) #if DEBUG Self.logDevToolsState( panel, event: "detach.afterSync", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 // Resign focus if WebKit currently owns first responder. if let window = webView.window ?? nsView.window { let state = Self.firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { #if DEBUG Self.logDevToolsState( panel, event: "detach.resignFirstResponder", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags ) #endif window.makeFirstResponder(nil) } } if webView.superview != nil { webView.removeFromSuperview() } nsView.subviews.forEach { $0.removeFromSuperview() } #if DEBUG Self.logDevToolsState( panel, event: "detach.done", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif return } if webView.superview !== nsView { // Cancel any pending retry; we'll reschedule if needed. context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachGeneration += 1 if let window = webView.window ?? nsView.window { let state = Self.firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { #if DEBUG Self.logDevToolsState( panel, event: "attach.reparent.resignFirstResponder.begin", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags ) #endif let resigned = window.makeFirstResponder(nil) #if DEBUG Self.logDevToolsState( panel, event: "attach.reparent.resignFirstResponder.end", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" ) #endif } } if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. if panel.shouldPreserveWebViewAttachmentDuringTransientHide() { panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow") #if DEBUG Self.logDevToolsState( panel, event: "attach.defer.requestRefresh", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif } #if DEBUG Self.logDevToolsState( panel, event: "attach.defer.offWindow", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif Self.scheduleAttachRetry( webView, panel: panel, to: nsView, coordinator: context.coordinator, generation: context.coordinator.attachGeneration ) } else { #if DEBUG Self.logDevToolsState( panel, event: "attach.immediate.begin", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif Self.attachWebView(webView, to: nsView) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG Self.logDevToolsState( panel, event: "attach.immediate", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif } } else { // Already attached; no need for any pending retry. context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG if hadPendingRefresh { Self.logDevToolsState( panel, event: "attach.alreadyAttached.consumePendingRefresh", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) } Self.logDevToolsState( panel, event: "attach.alreadyAttached", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) #endif } Self.applyFocus( panel: panel, webView: webView, nsView: nsView, shouldFocusWebView: shouldFocusWebView, isPanelFocused: isPanelFocused ) } private static func applyFocus( panel: BrowserPanel, webView: WKWebView, nsView: NSView, shouldFocusWebView: Bool, isPanelFocused: Bool ) { // Focus handling. Avoid fighting the address bar when it is focused. guard let window = nsView.window else { return } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { return } if responderChainContains(window.firstResponder, target: webView) { return } window.makeFirstResponder(webView) } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). window.makeFirstResponder(nil) } } private static func applyWebViewFirstResponderPolicy( panel: BrowserPanel, webView: WKWebView, isPanelFocused: Bool ) { guard let cmuxWebView = webView as? CmuxWebView else { return } let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() if cmuxWebView.allowsFirstResponderAcquisition != next { #if DEBUG dlog( "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" ) #endif } cmuxWebView.allowsFirstResponderAcquisition = next } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachRetryWorkItem?.cancel() coordinator.attachRetryWorkItem = nil coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 clearPortalCallbacks(for: nsView) guard let webView = coordinator.webView else { return } let panel = coordinator.panel if coordinator.usesWindowPortal { coordinator.usesWindowPortal = false coordinator.lastPortalHostId = nil // During split/layout churn we keep the WKWebView portal-hosted so DevTools // does not lose state. BrowserPanel deinit explicitly detaches on real teardown. if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { #if DEBUG logDevToolsState( panel, event: "dismantle.portal.keepAttached", generation: coordinator.attachGeneration, retryCount: coordinator.attachRetryCount, details: attachContext(webView: webView, host: nsView) ) #endif return } BrowserWindowPortalRegistry.detach(webView: webView) return } // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window if let window { let state = firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { #if DEBUG if let panel { logDevToolsState( panel, event: "dismantle.resignFirstResponder", generation: coordinator.attachGeneration, retryCount: coordinator.attachRetryCount, details: attachContext(webView: webView, host: nsView) + " " + state.flags ) } #endif window.makeFirstResponder(nil) } } // During split/layout churn, SwiftUI may tear down a host view while a new one is still // coming online. When DevTools is intended open, avoid eagerly detaching here. if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide(), webView.superview === nsView { #if DEBUG logDevToolsState( panel, event: "dismantle.skipDetach.devTools", generation: coordinator.attachGeneration, retryCount: coordinator.attachRetryCount, details: attachContext(webView: webView, host: nsView) ) #endif return } if webView.superview === nsView { webView.removeFromSuperview() #if DEBUG if let panel { logDevToolsState( panel, event: "dismantle.detached", generation: coordinator.attachGeneration, retryCount: coordinator.attachRetryCount, details: attachContext(webView: webView, host: nsView) ) } #endif } } }