3656 lines
144 KiB
Swift
3656 lines
144 KiB
Swift
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 {
|
|
@ViewBuilder
|
|
func cmuxFlatSymbolColorRendering() -> some View {
|
|
if #available(macOS 26.0, *) {
|
|
self.symbolColorRenderingMode(.flat)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
func resolvedBrowserChromeBackgroundColor(
|
|
for colorScheme: ColorScheme,
|
|
themeBackgroundColor: NSColor
|
|
) -> NSColor {
|
|
switch colorScheme {
|
|
case .dark, .light:
|
|
return themeBackgroundColor
|
|
@unknown default:
|
|
return themeBackgroundColor
|
|
}
|
|
}
|
|
|
|
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<Void, Never>?
|
|
@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 omnibarPillBackgroundColor: NSColor {
|
|
resolvedBrowserOmnibarPillBackgroundColor(
|
|
for: colorScheme,
|
|
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)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
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 applyPendingAddressBarFocusRequestIfNeeded() {
|
|
guard let requestId = panel.pendingAddressBarFocusRequestId 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 }
|
|
// 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<String>()
|
|
|
|
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<String>
|
|
) {
|
|
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
|
|
}
|
|
}
|
|
}
|