cmux/Sources/Panels/BrowserPanelView.swift
atani 3a5bd8494c
fix: avoid NSTextView tracking loop in omnibar mouseDown (#928)
* fix: avoid NSTextView tracking loop in omnibar mouseDown (#917)

Replace the synthetic mouseUp timeout workaround with direct cursor
positioning via NSTextView.characterIndexForInsertion(at:). The previous
approach posted a fake mouseUp event via NSApp.postEvent after 3 seconds,
but the NSTextView tracking loop does not always dequeue events from the
application event queue when stuck in an infinite
NSTextLayoutManager.enumerateTextLayoutFragments cycle, so the hang
persisted.

The new approach bypasses super.mouseDown entirely when the field editor
is already active, positioning the cursor (or extending the selection
with Shift+click) without entering the tracking loop. Drag-to-select is
not supported in this code path, but for a single-line omnibar this is
an acceptable trade-off.

* fix: handle double-click, UTF-16 length, and shift-click anchor

Address review feedback:

- Forward double/triple-click events to editor.mouseDown(with:) to
  preserve word and line selection without entering NSTextField's
  tracking loop
- Use (editor.string as NSString).length instead of String.count for
  NSRange clamping (NSRange uses UTF-16 indices)
- Track shift-click anchor independently via shiftClickAnchor property
  to correctly handle bidirectional selection extension

* fix: reset shiftClickAnchor on keyDown to prevent stale anchor

Clear the shift-click selection anchor whenever a key is pressed, so
that keyboard navigation (arrow keys, Shift+arrow, Home/End, etc.)
properly invalidates the mouse-originated anchor. A subsequent
Shift+click will then use the current selection position as anchor
instead of a stale value from a prior mouse interaction.

* fix: reset shiftClickAnchor in performKeyEquivalent and on re-focus

Key equivalents (Cmd+A, Cmd+V, etc.) bypass keyDown and go through
performKeyEquivalent, so the anchor must also be cleared there.
Similarly, re-focusing the field (currentEditor() == nil path) should
reset the anchor since selectAll changes the selection state.
2026-03-05 02:31:23 -08:00

3378 lines
132 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 {
func cmuxFlatSymbolColorRendering() -> some View {
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
// used by CI/local builds. Keep this modifier as a compatibility no-op.
self
}
}
func resolvedBrowserChromeBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
switch colorScheme {
case .dark, .light:
return themeBackgroundColor
@unknown default:
return themeBackgroundColor
}
}
func resolvedBrowserChromeColorScheme(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> ColorScheme {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
return backgroundColor.isLightColor ? .light : .dark
}
func resolvedBrowserOmnibarPillBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
let darkenMix: CGFloat
switch colorScheme {
case .light:
darkenMix = 0.04
case .dark:
darkenMix = 0.05
@unknown default:
darkenMix = 0.04
}
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
@State private var suggestionTask: Task<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
@State private var ghosttyBackgroundGeneration: Int = 0
// 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 browserChromeBackground: Color {
_ = ghosttyBackgroundGeneration
return Color(nsColor: GhosttyBackgroundTheme.currentColor())
}
private var browserChromeBackgroundColor: NSColor {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
}
private var browserChromeColorScheme: ColorScheme {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
}
var body: some View {
VStack(spacing: 0) {
addressBar
webView
}
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay {
if let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
)
}
}
.overlay(alignment: .topLeading) {
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
OmnibarSuggestionsView(
engineName: searchEngine.displayName,
items: omnibarState.suggestions,
selectedIndex: omnibarState.selectedSuggestionIndex,
isLoadingRemoteSuggestions: isLoadingRemoteSuggestions,
searchSuggestionsEnabled: remoteSuggestionsEnabled,
onCommit: { item in
commitSuggestion(item)
},
onHighlight: { idx in
let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx))
applyOmnibarEffects(effects)
}
)
.frame(width: omnibarPillFrame.width)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
.zIndex(1000)
.environment(\.colorScheme, browserChromeColorScheme)
}
}
.coordinateSpace(name: "BrowserPanelViewSpace")
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
omnibarPillFrame = frame
}
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
// Only handle clicks from our own webview.
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { _ in
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
onRequestPanelFocus()
}
.onAppear {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
}
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
}
.onChange(of: panel.currentURL) { _ in
let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
syncURLFromPanel()
// If we auto-focused a blank omnibar but then a URL loads programmatically, move focus
// into WebKit unless the user had already started typing.
if addressBarFocused,
!panel.shouldSuppressWebViewFocus(),
addressWasEmpty,
!isWebViewBlank() {
addressBarFocused = false
}
}
.onChange(of: browserThemeModeRaw) { _ in
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
if browserThemeModeRaw != normalizedMode.rawValue {
browserThemeModeRaw = normalizedMode.rawValue
}
panel.setBrowserThemeMode(normalizedMode)
}
.onChange(of: colorScheme) { _ in
panel.refreshAppearanceDrivenColors()
}
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: isFocused) { focused in
// Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive).
if focused {
applyPendingAddressBarFocusRequestIfNeeded()
autoFocusOmnibarIfBlank()
} else {
hideSuggestions()
addressBarFocused = false
}
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
}
.onChange(of: addressBarFocused) { focused in
let urlString = panel.preferredURLStringForOmnibar() ?? ""
if focused {
panel.beginSuppressWebViewFocusForAddressBar()
NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id)
// Only request panel focus if this pane isn't currently focused. When already
// focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
if !isFocused {
onRequestPanelFocus()
}
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
} else {
panel.endSuppressWebViewFocusForAddressBar()
NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id)
if suppressNextFocusLostRevert {
suppressNextFocusLostRevert = false
let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString))
applyOmnibarEffects(effects)
} else {
let effects = omnibarReduce(state: &omnibarState, event: .focusLostRevertBuffer(currentURLString: urlString))
applyOmnibarEffects(effects)
}
inlineCompletion = nil
}
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
guard addressBarFocused else { return }
refreshSuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: .browserDidBlurAddressBar).filter { note in
guard let panelId = note.object as? UUID else { return false }
return panelId == panel.id
}) { _ in
if addressBarFocused {
addressBarFocused = false
}
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
ghosttyBackgroundGeneration &+= 1
}
}
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(browserChromeBackground)
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
}
private var addressBarButtonBar: some View {
return HStack(spacing: 0) {
Button(action: {
#if DEBUG
dlog("browser.back panel=\(panel.id.uuidString.prefix(5))")
#endif
panel.goBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .medium))
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(OmnibarAddressButtonStyle())
.disabled(!panel.canGoBack)
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help(String(localized: "browser.goBack", defaultValue: "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(String(localized: "browser.goForward", defaultValue: "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 ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload"))
if panel.isDownloading {
HStack(spacing: 4) {
ProgressView()
.controlSize(.small)
Text(String(localized: "browser.downloading", defaultValue: "Downloading..."))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
.help(String(localized: "browser.downloadInProgress", defaultValue: "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(String(localized: "browser.toggleDevTools", defaultValue: "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: String(localized: "browser.addressBar.placeholder", defaultValue: "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 host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
.id(panel.webViewInstanceID)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
if addressBarFocused {
addressBarFocused = false
}
})
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
}
}
}
.zIndex(0)
}
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
}
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
private func syncWebViewResponderPolicyWithViewState(reason: String) {
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
let next = isFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) reason=\(reason)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
private func syncURLFromPanel() {
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString))
applyOmnibarEffects(effects)
}
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
guard let app = AppDelegate.shared else { return false }
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
return true
}
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
private func applyPendingAddressBarFocusRequestIfNeeded() {
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard lastHandledAddressBarFocusRequestId != requestId else { return }
lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar()
if addressBarFocused {
// Re-run focus behavior (select-all/refresh suggestions) when focus is
// explicitly requested again while already focused.
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
} else {
addressBarFocused = true
}
panel.acknowledgeAddressBarFocusRequest(requestId)
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
private func isWebViewBlank() -> Bool {
guard let url = panel.webView.url else { return true }
return url.absoluteString == "about:blank"
}
private func autoFocusOmnibarIfBlank() {
guard isFocused else { return }
guard !addressBarFocused else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
// If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
guard !panel.webView.isLoading else { return }
guard isWebViewBlank() else { return }
addressBarFocused = true
}
private func openDevTools() {
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
#endif
if !panel.toggleDeveloperTools() {
NSSound.beep()
}
}
private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) {
if browserThemeModeRaw != mode.rawValue {
browserThemeModeRaw = mode.rawValue
}
panel.setBrowserThemeMode(mode)
}
private func handleOmnibarTap() {
onRequestPanelFocus()
guard !addressBarFocused else { return }
// `focusPane` converges selection and can transiently move first responder to WebKit.
// Reassert omnibar focus on the next runloop for click-to-type behavior.
DispatchQueue.main.async {
addressBarFocused = true
}
}
private func hideSuggestions() {
suggestionTask?.cancel()
suggestionTask = nil
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([]))
applyOmnibarEffects(effects)
isLoadingRemoteSuggestions = false
inlineCompletion = nil
}
private func commitSelectedSuggestion() {
let idx = omnibarState.selectedSuggestionIndex
guard idx >= 0, idx < omnibarState.suggestions.count else { return }
commitSuggestion(omnibarState.suggestions[idx])
}
private func commitSuggestion(_ suggestion: OmnibarSuggestion) {
// Treat this as a commit, not a user edit: don't refetch suggestions while we're navigating away.
omnibarState.buffer = suggestion.completion
omnibarState.isUserEditing = false
switch suggestion.kind {
case .switchToTab(let tabId, let panelId, _, _):
AppDelegate.shared?.tabManager?.focusTab(tabId, surfaceId: panelId)
default:
panel.navigateSmart(suggestion.completion)
}
hideSuggestions()
inlineCompletion = nil
suppressNextFocusLostRevert = true
addressBarFocused = false
}
private func handleOmnibarEscape() {
guard addressBarFocused else { return }
// Chrome-like flow: clear inline completion first, then apply normal escape behavior.
if inlineCompletion != nil {
inlineCompletion = nil
return
}
let effects = omnibarReduce(state: &omnibarState, event: .escape)
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
private func handleOmnibarSelectionChange(range: NSRange, hasMarkedText: Bool) {
omnibarSelectionRange = range
omnibarHasMarkedText = hasMarkedText
refreshInlineCompletion()
}
private func acceptInlineCompletion() {
guard let completion = inlineCompletion else { return }
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(completion.displayText))
applyOmnibarEffects(effects)
inlineCompletion = nil
}
private func handleInlineBackspace() {
guard let completion = inlineCompletion else { return }
let prefix = completion.typedText
guard !prefix.isEmpty else { return }
let updated = String(prefix.dropLast())
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(updated))
applyOmnibarEffects(effects)
omnibarSelectionRange = NSRange(location: updated.utf16.count, length: 0)
refreshInlineCompletion()
}
private func deleteSelectedSuggestionIfPossible() {
let idx = omnibarState.selectedSuggestionIndex
guard idx >= 0, idx < omnibarState.suggestions.count else { return }
let target = omnibarState.suggestions[idx]
guard case .history(let url, _) = target.kind else { return }
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
refreshSuggestions()
}
private func refreshInlineCompletion() {
inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: omnibarState.buffer,
suggestions: omnibarState.suggestions,
isFocused: addressBarFocused,
selectionRange: omnibarSelectionRange,
hasMarkedText: omnibarHasMarkedText
)
}
private func refreshSuggestions() {
suggestionTask?.cancel()
suggestionTask = nil
isLoadingRemoteSuggestions = false
guard addressBarFocused else {
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([]))
applyOmnibarEffects(effects)
return
}
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
let historyEntries: [BrowserHistoryStore.Entry] = {
if query.isEmpty {
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
}
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
}()
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
let staleRemote: [String]
if query.isEmpty || isSingleCharacterQuery {
staleRemote = []
} else {
staleRemote = staleRemoteSuggestionsForDisplay(query: query)
}
let resolvedURL = query.isEmpty ? nil : panel.resolveNavigableURL(from: query)
let items = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: historyEntries,
openTabMatches: openTabMatches,
remoteQueries: staleRemote,
resolvedURL: resolvedURL,
limit: 8
)
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items))
applyOmnibarEffects(effects)
refreshInlineCompletion()
guard !query.isEmpty else { return }
if !isSingleCharacterQuery, let forcedRemote = forcedRemoteSuggestionsForUITest() {
latestRemoteSuggestionQuery = query
latestRemoteSuggestions = forcedRemote
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: historyEntries,
openTabMatches: openTabMatches,
remoteQueries: forcedRemote,
resolvedURL: resolvedURL,
limit: 8
)
let forcedEffects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
applyOmnibarEffects(forcedEffects)
refreshInlineCompletion()
return
}
guard remoteSuggestionsEnabled else { return }
guard !isSingleCharacterQuery else { return }
guard omnibarInputIntent(for: query) != .urlLike else { return }
// Keep current remote rows visible while fetching fresh predictions.
let engine = searchEngine
isLoadingRemoteSuggestions = true
suggestionTask = Task {
let remote = await BrowserSearchSuggestionService.shared.suggestions(engine: engine, query: query)
if Task.isCancelled { return }
await MainActor.run {
guard addressBarFocused else { return }
let current = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
guard current == query else { return }
latestRemoteSuggestionQuery = query
latestRemoteSuggestions = remote
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
remoteQueries: remote,
resolvedURL: panel.resolveNavigableURL(from: query),
limit: 8
)
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
applyOmnibarEffects(effects)
refreshInlineCompletion()
isLoadingRemoteSuggestions = false
}
}
}
private func staleRemoteSuggestionsForDisplay(query: String) -> [String] {
staleOmnibarRemoteSuggestionsForDisplay(
query: query,
previousRemoteQuery: latestRemoteSuggestionQuery,
previousRemoteSuggestions: latestRemoteSuggestions
)
}
private func matchingOpenTabSuggestions(for query: String, limit: Int) -> [OmnibarOpenTabMatch] {
guard !query.isEmpty, limit > 0 else { return [] }
let loweredQuery = query.lowercased()
let singleCharacterQuery = omnibarSingleCharacterQuery(for: query)
let includeCurrentPanelForSingleCharacterQuery = singleCharacterQuery != nil
let tabManager = AppDelegate.shared?.tabManager
let currentPanelWorkspaceId = tabManager?.tabs.first(where: { tab in
tab.panels[panel.id] is BrowserPanel
})?.id
var matches: [OmnibarOpenTabMatch] = []
var seenKeys = Set<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 String(localized: "browser.switchToTab", defaultValue: "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)?
/// Anchor index for Shift+click selection extension, reset on non-shift clicks.
private var shiftClickAnchor: Int?
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)
shiftClickAnchor = nil
} else {
// Already editing place the cursor at the click position without calling
// super.mouseDown, which enters NSTextView's mouse-tracking loop. That loop
// can spin forever when NSTextLayoutManager.enumerateTextLayoutFragments hits
// an infinite invalidation cycle (see #917). The previous mitigation posted a
// synthetic mouseUp via NSApp.postEvent after a timeout, but the tracking loop
// does not always dequeue events from the application event queue, so the hang
// persisted. By positioning the cursor ourselves we avoid the tracking loop
// entirely. Drag-to-select is not supported in this path, but for a single-line
// omnibar this is an acceptable trade-off (double-click to select word and
// Shift+click to extend selection still work via the field editor).
guard let editor = currentEditor() as? NSTextView else {
super.mouseDown(with: event)
return
}
// Double/triple-click: forward directly to the field editor (NSTextView)
// which handles word and line selection internally. This bypasses
// NSTextField's super.mouseDown (and its problematic tracking loop)
// while preserving multi-click semantics.
if event.clickCount > 1 {
editor.mouseDown(with: event)
shiftClickAnchor = nil
return
}
let localPoint = editor.convert(event.locationInWindow, from: nil)
let index = editor.characterIndexForInsertion(at: localPoint)
let textLength = (editor.string as NSString).length
let safeIndex = min(index, textLength)
if event.modifierFlags.contains(.shift) {
// Shift+click: extend the existing selection to the clicked position.
// Use stored anchor to handle bidirectional extension correctly;
// NSRange.location is always the lower index so it cannot serve as
// a directional anchor on its own.
let sel = editor.selectedRange()
let anchor = shiftClickAnchor ?? sel.location
shiftClickAnchor = anchor
let newRange: NSRange
if safeIndex >= anchor {
newRange = NSRange(location: anchor, length: safeIndex - anchor)
} else {
newRange = NSRange(location: safeIndex, length: anchor - safeIndex)
}
editor.setSelectedRange(newRange)
} else {
shiftClickAnchor = nil
editor.setSelectedRange(NSRange(location: safeIndex, length: 0))
}
}
}
override func keyDown(with event: NSEvent) {
// Reset shift-click anchor on any keyboard input so that a subsequent
// Shift+click uses the post-keyboard selection as its anchor, not a
// stale value from a prior mouse interaction.
shiftClickAnchor = nil
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
super.keyDown(with: event)
return
}
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
return
}
super.keyDown(with: event)
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
shiftClickAnchor = nil
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
return super.performKeyEquivalent(with: event)
}
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.hasMarkedText(), 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, !editor.hasMarkedText() {
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(String(localized: "browser.addressBarSuggestions", defaultValue: "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 attachGeneration: Int = 0
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: 0,
details: Self.attachContext(webView: webView, host: host)
)
#endif
}
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
let coordinator = context.coordinator
if let previousWebView = coordinator.webView, previousWebView !== webView {
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
}
coordinator.panel = panel
coordinator.webView = webView
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused
)
Self.clearPortalCallbacks(for: nsView)
updateUsingWindowPortal(nsView, context: context, webView: webView)
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.attachGeneration += 1
clearPortalCallbacks(for: nsView)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
// 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: 0,
details: attachContext(webView: webView, host: nsView) + " " + state.flags
)
}
#endif
window.makeFirstResponder(nil)
}
}
BrowserWindowPortalRegistry.detach(webView: webView)
coordinator.lastPortalHostId = nil
}
}