* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser After a browser panel is shown, SwiftUI's internal focus system activates and its _NSHostingView starts consuming arrow keys and other non-Command key events via performKeyEquivalent, preventing them from reaching the terminal's keyDown handler. Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is the first responder and the event has no Command modifier, route directly to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy walk entirely. Also clear stale browserAddressBarFocusedPanelId when a terminal surface has focus, preventing Cmd+N from being eaten by omnibar selection logic after focus transitions away from a browser. Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to /tmp/cmux-key-debug.log for diagnosing future key routing issues. * Fix split focus and Cmd+Shift+N swallowed after opening browser Split focus: capture the source terminal's hostedView before bonsplit mutates focusedPaneId, so focusPanel moves focus FROM the old pane instead of from the new pane to itself. Also retry ensureFocus when the new terminal's view has no window yet (matching the existing retry pattern for isVisibleInUI). Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's internal focus system can intercept Command-key events in the content view hierarchy (returning true) without firing the CommandGroup action closure. Fix by dispatching Command-key events directly to NSApp.mainMenu when the terminal is first responder, bypassing the broken SwiftUI path. Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and doesn't depend on SwiftUI menu dispatch at all. * Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log - Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate - Replace all klog/mlog calls with dlog() (provided by bonsplit) - Remove debugLogCallback wiring from Workspace - Add focus change logging: focus.panel, focus.firstResponder, split.created, focus.moveFocus - Add import Bonsplit where needed for dlog access - Fix stale drag state on cancelled tab drags (bonsplit submodule) * Fix split focus stolen by re-entrant becomeFirstResponder during reparenting During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the OLD panel, stealing focus from the newly created pane. Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel calls for non-target panels during split creation. Also document the unified debug event log in CLAUDE.md. * Clear stale title/favicon when browser navigation fails When a page fails to load (e.g. connection refused), the tab was still showing the previous page's title and favicon. Now didFailProvisionalNavigation resets pageTitle to the failed URL and clears faviconPNGData. * Fix Cmd+N swallowed by browser omnibar and improve split focus suppression - Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should always create new workspace regardless of address bar focus) - Move split focus suppression from workspace-level guard to source: suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus) directly on the old GhosttyNSView during reparenting, preventing both model-level and libghostty-level focus divergence - Remove programmaticFocusTargetPanelId from Workspace.focusPanel * Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin - Omnibar: first click selects all without entering NSTextView tracking loop; subsequent clicks have 3s synthetic mouseUp safety net to prevent hang - WebView: set underPageBackgroundColor to match window so new browsers don't flash white before content loads - Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist so WKWebView doesn't intercept tab drags; override registerForDraggedTypes on CmuxWebView as belt-and-suspenders - CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by checking both the text field and its field editor (the actual first responder)
2495 lines
97 KiB
Swift
2495 lines
97 KiB
Swift
import Bonsplit
|
|
import SwiftUI
|
|
import WebKit
|
|
import AppKit
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
/// View for rendering a browser panel with address bar
|
|
struct BrowserPanelView: View {
|
|
@ObservedObject var panel: BrowserPanel
|
|
let isFocused: Bool
|
|
let isVisibleInUI: Bool
|
|
let onRequestPanelFocus: () -> Void
|
|
@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
|
|
@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 focusFlashFadeWorkItem: DispatchWorkItem?
|
|
@State private var omnibarPillFrame: CGRect = .zero
|
|
private let omnibarPillCornerRadius: CGFloat = 12
|
|
|
|
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
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
addressBar
|
|
webView
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3)
|
|
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10)
|
|
.padding(6)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
|
|
OmnibarSuggestionsView(
|
|
engineName: searchEngine.displayName,
|
|
items: omnibarState.suggestions,
|
|
selectedIndex: omnibarState.selectedSuggestionIndex,
|
|
isLoadingRemoteSuggestions: isLoadingRemoteSuggestions,
|
|
searchSuggestionsEnabled: remoteSuggestionsEnabled,
|
|
onCommit: { item in
|
|
commitSuggestion(item)
|
|
},
|
|
onHighlight: { idx in
|
|
let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx))
|
|
applyOmnibarEffects(effects)
|
|
}
|
|
)
|
|
.frame(width: omnibarPillFrame.width)
|
|
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
|
|
.zIndex(1000)
|
|
}
|
|
}
|
|
.coordinateSpace(name: "BrowserPanelViewSpace")
|
|
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
|
|
omnibarPillFrame = frame
|
|
}
|
|
.onAppear {
|
|
UserDefaults.standard.register(defaults: [
|
|
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
|
|
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
|
|
])
|
|
syncURLFromPanel()
|
|
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
|
|
autoFocusOmnibarIfBlank()
|
|
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: isFocused) { focused in
|
|
// Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive).
|
|
if focused {
|
|
autoFocusOmnibarIfBlank()
|
|
} else {
|
|
hideSuggestions()
|
|
addressBarFocused = false
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .browserFocusAddressBar)) { notification in
|
|
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
|
panel.beginSuppressWebViewFocusForAddressBar()
|
|
addressBarFocused = true
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
|
|
private var addressBar: some View {
|
|
HStack(spacing: 8) {
|
|
addressBarButtonBar
|
|
|
|
omnibarField
|
|
.accessibilityIdentifier("BrowserOmnibarPill")
|
|
.accessibilityLabel("Browser omnibar")
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
|
.zIndex(1)
|
|
}
|
|
|
|
private var addressBarButtonBar: some View {
|
|
let navButtonSize: CGFloat = 22
|
|
|
|
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: navButtonSize, height: navButtonSize, alignment: .center)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
|
.disabled(!panel.canGoBack)
|
|
.opacity(panel.canGoBack ? 1.0 : 0.4)
|
|
.help("Go Back")
|
|
|
|
Button(action: {
|
|
#if DEBUG
|
|
dlog("browser.forward panel=\(panel.id.uuidString.prefix(5))")
|
|
#endif
|
|
panel.goForward()
|
|
}) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
|
.disabled(!panel.canGoForward)
|
|
.opacity(panel.canGoForward ? 1.0 : 0.4)
|
|
.help("Go Forward")
|
|
|
|
Button(action: {
|
|
if panel.isLoading {
|
|
#if DEBUG
|
|
dlog("browser.stop panel=\(panel.id.uuidString.prefix(5))")
|
|
#endif
|
|
panel.stopLoading()
|
|
} else {
|
|
#if DEBUG
|
|
dlog("browser.reload panel=\(panel.id.uuidString.prefix(5))")
|
|
#endif
|
|
panel.reload()
|
|
}
|
|
}) {
|
|
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
|
.help(panel.isLoading ? "Stop" : "Reload")
|
|
}
|
|
}
|
|
|
|
private var omnibarField: some View {
|
|
let showSecureBadge = panel.currentURL?.scheme == "https"
|
|
|
|
return HStack(spacing: 4) {
|
|
if showSecureBadge {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
OmnibarTextFieldRepresentable(
|
|
text: Binding(
|
|
get: { omnibarState.buffer },
|
|
set: { newValue in
|
|
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(newValue))
|
|
applyOmnibarEffects(effects)
|
|
refreshInlineCompletion()
|
|
}
|
|
),
|
|
isFocused: $addressBarFocused,
|
|
inlineCompletion: inlineCompletion,
|
|
placeholder: "Search or enter URL",
|
|
onTap: {
|
|
handleOmnibarTap()
|
|
},
|
|
onSubmit: {
|
|
if addressBarFocused, !omnibarState.suggestions.isEmpty {
|
|
commitSelectedSuggestion()
|
|
} else {
|
|
panel.navigateSmart(omnibarState.buffer)
|
|
hideSuggestions()
|
|
suppressNextFocusLostRevert = true
|
|
addressBarFocused = false
|
|
}
|
|
},
|
|
onEscape: {
|
|
handleOmnibarEscape()
|
|
},
|
|
onFieldLostFocus: {
|
|
addressBarFocused = false
|
|
},
|
|
onMoveSelection: { delta in
|
|
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
|
|
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
|
|
applyOmnibarEffects(effects)
|
|
refreshInlineCompletion()
|
|
},
|
|
onDeleteSelectedSuggestion: {
|
|
deleteSelectedSuggestionIfPossible()
|
|
},
|
|
onAcceptInlineCompletion: {
|
|
acceptInlineCompletion()
|
|
},
|
|
onDeleteBackwardWithInlineSelection: {
|
|
handleInlineBackspace()
|
|
},
|
|
onSelectionChanged: { selectionRange, hasMarkedText in
|
|
handleOmnibarSelectionChange(range: selectionRange, hasMarkedText: hasMarkedText)
|
|
},
|
|
shouldSuppressWebViewFocus: {
|
|
panel.shouldSuppressWebViewFocus()
|
|
}
|
|
)
|
|
.frame(height: 18)
|
|
.accessibilityIdentifier("BrowserOmnibarTextField")
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
|
.fill(Color(nsColor: .textBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
|
.stroke(addressBarFocused ? Color.accentColor : 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 {
|
|
WebViewRepresentable(
|
|
panel: panel,
|
|
shouldAttachWebView: isVisibleInUI,
|
|
shouldFocusWebView: isFocused && !addressBarFocused
|
|
)
|
|
// Keep the representable identity stable across bonsplit structural updates.
|
|
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
|
|
.id(panel.id)
|
|
.contentShape(Rectangle())
|
|
.simultaneousGesture(TapGesture().onEnded {
|
|
// Chrome-like behavior: clicking web content while editing the
|
|
// omnibar should commit blur and revert transient edits.
|
|
if addressBarFocused {
|
|
addressBarFocused = false
|
|
}
|
|
})
|
|
.zIndex(0)
|
|
.contextMenu {
|
|
Button("Open Developer Tools") {
|
|
openDevTools()
|
|
}
|
|
.keyboardShortcut("i", modifiers: [.command, .option])
|
|
}
|
|
}
|
|
|
|
private func triggerFocusFlashAnimation() {
|
|
focusFlashFadeWorkItem?.cancel()
|
|
focusFlashFadeWorkItem = nil
|
|
|
|
withAnimation(.easeOut(duration: 0.08)) {
|
|
focusFlashOpacity = 1.0
|
|
}
|
|
|
|
let item = DispatchWorkItem {
|
|
withAnimation(.easeOut(duration: 0.35)) {
|
|
focusFlashOpacity = 0.0
|
|
}
|
|
}
|
|
focusFlashFadeWorkItem = item
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item)
|
|
}
|
|
|
|
private func syncURLFromPanel() {
|
|
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
|
let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString))
|
|
applyOmnibarEffects(effects)
|
|
}
|
|
|
|
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
|
|
private func isWebViewBlank() -> Bool {
|
|
guard let url = panel.webView.url else { return true }
|
|
return url.absoluteString == "about:blank"
|
|
}
|
|
|
|
private func autoFocusOmnibarIfBlank() {
|
|
guard isFocused else { return }
|
|
guard !addressBarFocused else { return }
|
|
// If a test/automation explicitly focused WebKit, don't steal focus back.
|
|
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
|
|
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
|
|
guard !panel.webView.isLoading else { return }
|
|
guard isWebViewBlank() else { return }
|
|
addressBarFocused = true
|
|
}
|
|
|
|
private func openDevTools() {
|
|
// WKWebView with developerExtrasEnabled allows right-click > Inspect Element
|
|
// We can also trigger via JavaScript
|
|
Task {
|
|
try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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] {
|
|
if ranked.score > existing.score {
|
|
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 }
|
|
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
|
|
}
|
|
|
|
private func typedQueryHasExplicitPathOrQuery(_ typedQuery: String) -> Bool {
|
|
var normalized = typedQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if normalized.hasPrefix("https://") {
|
|
normalized.removeFirst("https://".count)
|
|
} else if normalized.hasPrefix("http://") {
|
|
normalized.removeFirst("http://".count)
|
|
}
|
|
return normalized.contains("/") || normalized.contains("?") || normalized.contains("#")
|
|
}
|
|
|
|
private func inlineCompletionHostDisplayText(
|
|
for acceptedText: String,
|
|
typedIncludesScheme: Bool,
|
|
typedIncludesWWWPrefix: Bool
|
|
) -> String? {
|
|
guard let components = URLComponents(string: acceptedText),
|
|
var host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
!host.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if !typedIncludesWWWPrefix, host.hasPrefix("www.") {
|
|
host.removeFirst("www.".count)
|
|
}
|
|
|
|
let portSuffix: String
|
|
if let port = components.port {
|
|
let scheme = components.scheme?.lowercased()
|
|
let isDefaultPort =
|
|
(scheme == "https" && port == 443) ||
|
|
(scheme == "http" && port == 80)
|
|
portSuffix = isDefaultPort ? "" : ":\(port)"
|
|
} else {
|
|
portSuffix = ""
|
|
}
|
|
|
|
let hostWithPort = "\(host)\(portSuffix)"
|
|
if typedIncludesScheme {
|
|
let scheme = (components.scheme?.lowercased() == "http") ? "http" : "https"
|
|
return "\(scheme)://\(hostWithPort)"
|
|
}
|
|
return hostWithPort
|
|
}
|
|
|
|
private func stripHTTPSchemePrefix(_ raw: String) -> String {
|
|
var normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if normalized.hasPrefix("https://") {
|
|
normalized.removeFirst("https://".count)
|
|
} else if normalized.hasPrefix("http://") {
|
|
normalized.removeFirst("http://".count)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private func stripHTTPSchemeAndWWWPrefix(_ raw: String) -> String {
|
|
var normalized = stripHTTPSchemePrefix(raw)
|
|
if normalized.hasPrefix("www.") {
|
|
normalized.removeFirst("www.".count)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private struct OmnibarPillFramePreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGRect = .zero
|
|
|
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
let next = nextValue()
|
|
if next != .zero {
|
|
value = next
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Omnibar State Machine
|
|
|
|
struct OmnibarState: Equatable {
|
|
var isFocused: Bool = false
|
|
var currentURLString: String = ""
|
|
var buffer: String = ""
|
|
var suggestions: [OmnibarSuggestion] = []
|
|
var selectedSuggestionIndex: Int = 0
|
|
var selectedSuggestionID: String?
|
|
var isUserEditing: Bool = false
|
|
}
|
|
|
|
enum OmnibarEvent: Equatable {
|
|
case focusGained(currentURLString: String)
|
|
case focusLostRevertBuffer(currentURLString: String)
|
|
case focusLostPreserveBuffer(currentURLString: String)
|
|
case panelURLChanged(currentURLString: String)
|
|
case bufferChanged(String)
|
|
case suggestionsUpdated([OmnibarSuggestion])
|
|
case moveSelection(delta: Int)
|
|
case highlightIndex(Int)
|
|
case escape
|
|
}
|
|
|
|
struct OmnibarEffects: Equatable {
|
|
var shouldSelectAll: Bool = false
|
|
var shouldBlurToWebView: Bool = false
|
|
var shouldRefreshSuggestions: Bool = false
|
|
}
|
|
|
|
@discardableResult
|
|
func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEffects {
|
|
var effects = OmnibarEffects()
|
|
|
|
switch event {
|
|
case .focusGained(let url):
|
|
state.isFocused = true
|
|
state.currentURLString = url
|
|
state.buffer = url
|
|
state.isUserEditing = false
|
|
state.suggestions = []
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
effects.shouldSelectAll = true
|
|
|
|
case .focusLostRevertBuffer(let url):
|
|
state.isFocused = false
|
|
state.currentURLString = url
|
|
state.buffer = url
|
|
state.isUserEditing = false
|
|
state.suggestions = []
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
|
|
case .focusLostPreserveBuffer(let url):
|
|
state.isFocused = false
|
|
state.currentURLString = url
|
|
state.isUserEditing = false
|
|
state.suggestions = []
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
|
|
case .panelURLChanged(let url):
|
|
state.currentURLString = url
|
|
if !state.isUserEditing {
|
|
state.buffer = url
|
|
state.suggestions = []
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
}
|
|
|
|
case .bufferChanged(let newValue):
|
|
state.buffer = newValue
|
|
if state.isFocused {
|
|
state.isUserEditing = (newValue != state.currentURLString)
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
effects.shouldRefreshSuggestions = true
|
|
}
|
|
|
|
case .suggestionsUpdated(let items):
|
|
let previousItems = state.suggestions
|
|
let previousSelectedID = state.selectedSuggestionID
|
|
state.suggestions = items
|
|
if items.isEmpty {
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
} else if let previousSelectedID,
|
|
let existingIdx = items.firstIndex(where: { $0.id == previousSelectedID }) {
|
|
state.selectedSuggestionIndex = existingIdx
|
|
state.selectedSuggestionID = items[existingIdx].id
|
|
} else if let preferredSuggestionIndex = omnibarPreferredAutocompletionSuggestionIndex(
|
|
suggestions: items,
|
|
query: state.buffer
|
|
) {
|
|
state.selectedSuggestionIndex = preferredSuggestionIndex
|
|
state.selectedSuggestionID = items[preferredSuggestionIndex].id
|
|
} else if previousItems.isEmpty {
|
|
// Popup reopened: start keyboard focus from the first row.
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = items[0].id
|
|
} else if let previousSelectedID,
|
|
let idx = items.firstIndex(where: { $0.id == previousSelectedID }) {
|
|
state.selectedSuggestionIndex = idx
|
|
state.selectedSuggestionID = items[idx].id
|
|
} else {
|
|
state.selectedSuggestionIndex = min(max(0, state.selectedSuggestionIndex), items.count - 1)
|
|
state.selectedSuggestionID = items[state.selectedSuggestionIndex].id
|
|
}
|
|
|
|
case .moveSelection(let delta):
|
|
guard !state.suggestions.isEmpty else { break }
|
|
state.selectedSuggestionIndex = min(
|
|
max(0, state.selectedSuggestionIndex + delta),
|
|
state.suggestions.count - 1
|
|
)
|
|
state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
|
|
|
|
case .highlightIndex(let idx):
|
|
guard !state.suggestions.isEmpty else { break }
|
|
state.selectedSuggestionIndex = min(max(0, idx), state.suggestions.count - 1)
|
|
state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
|
|
|
|
case .escape:
|
|
guard state.isFocused else { break }
|
|
// Chrome semantics:
|
|
// - If user input is in progress OR the popup is open: revert to the page URL and select-all.
|
|
// - Otherwise: exit omnibar focus.
|
|
if state.isUserEditing || !state.suggestions.isEmpty {
|
|
state.isUserEditing = false
|
|
state.buffer = state.currentURLString
|
|
state.suggestions = []
|
|
state.selectedSuggestionIndex = 0
|
|
state.selectedSuggestionID = nil
|
|
effects.shouldSelectAll = true
|
|
} else {
|
|
effects.shouldBlurToWebView = true
|
|
}
|
|
}
|
|
|
|
return effects
|
|
}
|
|
|
|
struct OmnibarSuggestion: Identifiable, Hashable {
|
|
enum Kind: Hashable {
|
|
case search(engineName: String, query: String)
|
|
case navigate(url: String)
|
|
case history(url: String, title: String?)
|
|
case switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?)
|
|
case remote(query: String)
|
|
}
|
|
|
|
let kind: Kind
|
|
|
|
// Stable identity prevents row teardown/rebuild flicker while typing.
|
|
var id: String {
|
|
switch kind {
|
|
case .search(let engineName, let query):
|
|
return "search|\(engineName.lowercased())|\(query.lowercased())"
|
|
case .navigate(let url):
|
|
return "navigate|\(url.lowercased())"
|
|
case .history(let url, _):
|
|
return "history|\(url.lowercased())"
|
|
case .switchToTab(let tabId, let panelId, let url, _):
|
|
return "switch-tab|\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())"
|
|
case .remote(let query):
|
|
return "remote|\(query.lowercased())"
|
|
}
|
|
}
|
|
|
|
var completion: String {
|
|
switch kind {
|
|
case .search(_, let q): return q
|
|
case .navigate(let url): return url
|
|
case .history(let url, _): return url
|
|
case .switchToTab(_, _, let url, _): return url
|
|
case .remote(let q): return q
|
|
}
|
|
}
|
|
|
|
var primaryText: String {
|
|
switch kind {
|
|
case .search(let engineName, let q):
|
|
return "Search \(engineName) for \"\(q)\""
|
|
case .navigate(let url):
|
|
return Self.displayURLText(for: url)
|
|
case .history(let url, let title):
|
|
return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
? Self.singleLineText(title) : Self.displayURLText(for: url)
|
|
case .switchToTab(_, _, let url, let title):
|
|
return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
? Self.singleLineText(title) : Self.displayURLText(for: url)
|
|
case .remote(let q):
|
|
return q
|
|
}
|
|
}
|
|
|
|
var listText: String {
|
|
switch kind {
|
|
case .history(let url, let title), .switchToTab(_, _, let url, let title):
|
|
let titleOneline = Self.singleLineText(title)
|
|
guard !titleOneline.isEmpty else { return Self.displayURLText(for: url) }
|
|
return "\(titleOneline) — \(Self.displayURLText(for: url))"
|
|
default:
|
|
return primaryText
|
|
}
|
|
}
|
|
|
|
var secondaryText: String? {
|
|
switch kind {
|
|
case .history(let url, let title):
|
|
let titleOneline = Self.singleLineText(title)
|
|
return titleOneline.isEmpty ? nil : Self.displayURLText(for: url)
|
|
case .switchToTab(_, _, let url, let title):
|
|
let titleOneline = Self.singleLineText(title)
|
|
return titleOneline.isEmpty ? nil : Self.displayURLText(for: url)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var trailingBadgeText: String? {
|
|
switch kind {
|
|
case .switchToTab:
|
|
return "Switch to tab"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var isHistoryRemovable: Bool {
|
|
if case .history = kind { return true }
|
|
return false
|
|
}
|
|
|
|
static func history(_ entry: BrowserHistoryStore.Entry) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .history(url: entry.url, title: entry.title))
|
|
}
|
|
|
|
static func history(url: String, title: String?) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .history(url: url, title: title))
|
|
}
|
|
|
|
static func search(engineName: String, query: String) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .search(engineName: engineName, query: query))
|
|
}
|
|
|
|
static func navigate(url: String) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .navigate(url: url))
|
|
}
|
|
|
|
static func switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .switchToTab(tabId: tabId, panelId: panelId, url: url, title: title))
|
|
}
|
|
|
|
private static func singleLineText(_ value: String?) -> String {
|
|
var normalized = (value ?? "").replacingOccurrences(of: "\r", with: " ")
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
.replacingOccurrences(of: "\t", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
while normalized.contains(" ") {
|
|
let collapsed = normalized.replacingOccurrences(of: " ", with: " ")
|
|
if collapsed == normalized { break }
|
|
normalized = collapsed
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
static func remoteSearchSuggestion(_ query: String) -> OmnibarSuggestion {
|
|
OmnibarSuggestion(kind: .remote(query: query))
|
|
}
|
|
|
|
private static func displayURLText(for rawURL: String) -> String {
|
|
guard let components = URLComponents(string: rawURL),
|
|
var host = components.host else {
|
|
return rawURL
|
|
}
|
|
|
|
if host.hasPrefix("www.") {
|
|
host.removeFirst(4)
|
|
}
|
|
host = host.lowercased()
|
|
|
|
var result = host
|
|
if let port = components.port {
|
|
result += ":\(port)"
|
|
}
|
|
|
|
let path = components.percentEncodedPath
|
|
if !path.isEmpty, path != "/" {
|
|
result += path
|
|
} else if path == "/" {
|
|
result += "/"
|
|
}
|
|
|
|
if let query = components.percentEncodedQuery, !query.isEmpty {
|
|
result += "?\(query)"
|
|
}
|
|
|
|
if result.isEmpty { return rawURL }
|
|
return result
|
|
}
|
|
}
|
|
|
|
private final class OmnibarNativeTextField: NSTextField {
|
|
var onPointerDown: (() -> Void)?
|
|
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
isBordered = false
|
|
isBezeled = false
|
|
drawsBackground = false
|
|
focusRingType = .none
|
|
lineBreakMode = .byTruncatingTail
|
|
usesSingleLineMode = true
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
#if DEBUG
|
|
dlog("browser.omnibarClick")
|
|
#endif
|
|
onPointerDown?()
|
|
|
|
if currentEditor() == nil {
|
|
// First click — activate editing and select all (standard URL bar behavior).
|
|
// Avoids NSTextView's tracking loop which can spin forever if text layout
|
|
// enters an infinite invalidation cycle (e.g. under memory pressure).
|
|
window?.makeFirstResponder(self)
|
|
currentEditor()?.selectAll(nil)
|
|
} else {
|
|
// Already editing — allow normal click-to-place-cursor and drag-to-select.
|
|
// Guard against a stuck tracking loop by posting a synthetic mouseUp after
|
|
// a timeout so the main thread can't be blocked indefinitely.
|
|
var trackingFinished = false
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
|
guard !trackingFinished, let self, let window = self.window else { return }
|
|
#if DEBUG
|
|
dlog("browser.omnibarTrackingTimeout — forcing mouseUp")
|
|
#endif
|
|
if let fakeUp = NSEvent.mouseEvent(
|
|
with: .leftMouseUp,
|
|
location: event.locationInWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 0,
|
|
clickCount: 1,
|
|
pressure: 0.0
|
|
) {
|
|
NSApp.postEvent(fakeUp, atStart: true)
|
|
}
|
|
}
|
|
super.mouseDown(with: event)
|
|
trackingFinished = true
|
|
}
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
|
|
return
|
|
}
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
|
|
return true
|
|
}
|
|
return super.performKeyEquivalent(with: event)
|
|
}
|
|
}
|
|
|
|
private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|
@Binding var text: String
|
|
@Binding var isFocused: Bool
|
|
let inlineCompletion: OmnibarInlineCompletion?
|
|
let placeholder: String
|
|
let onTap: () -> Void
|
|
let onSubmit: () -> Void
|
|
let onEscape: () -> Void
|
|
let onFieldLostFocus: () -> Void
|
|
let onMoveSelection: (Int) -> Void
|
|
let onDeleteSelectedSuggestion: () -> Void
|
|
let onAcceptInlineCompletion: () -> Void
|
|
let onDeleteBackwardWithInlineSelection: () -> Void
|
|
let onSelectionChanged: (NSRange, Bool) -> Void
|
|
let shouldSuppressWebViewFocus: () -> Bool
|
|
|
|
final class Coordinator: NSObject, NSTextFieldDelegate {
|
|
var parent: OmnibarTextFieldRepresentable
|
|
var isProgrammaticMutation: Bool = false
|
|
var selectionObserver: NSObjectProtocol?
|
|
weak var observedEditor: NSTextView?
|
|
var appliedInlineCompletion: OmnibarInlineCompletion?
|
|
var lastPublishedSelection: NSRange = NSRange(location: NSNotFound, length: 0)
|
|
var lastPublishedHasMarkedText: Bool = false
|
|
|
|
init(parent: OmnibarTextFieldRepresentable) {
|
|
self.parent = parent
|
|
}
|
|
|
|
deinit {
|
|
if let selectionObserver {
|
|
NotificationCenter.default.removeObserver(selectionObserver)
|
|
}
|
|
}
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
if !parent.isFocused {
|
|
DispatchQueue.main.async {
|
|
self.parent.isFocused = true
|
|
}
|
|
}
|
|
attachSelectionObserverIfNeeded()
|
|
publishSelectionState()
|
|
}
|
|
|
|
func controlTextDidEndEditing(_ obj: Notification) {
|
|
if parent.isFocused {
|
|
if parent.shouldSuppressWebViewFocus() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
guard self.parent.isFocused else { return }
|
|
guard self.parent.shouldSuppressWebViewFocus() else { return }
|
|
guard let field = self.parentField, let window = field.window else { 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 ||
|
|
((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 }
|
|
parent.text = field.stringValue
|
|
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(_:)):
|
|
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
|
|
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 desiredDisplayText = inlineCompletion?.displayText ?? text
|
|
if let editor = nsView.currentEditor() as? NSTextView {
|
|
if editor.string != desiredDisplayText {
|
|
context.coordinator.isProgrammaticMutation = true
|
|
editor.string = desiredDisplayText
|
|
nsView.stringValue = desiredDisplayText
|
|
context.coordinator.isProgrammaticMutation = false
|
|
}
|
|
} else if nsView.stringValue != desiredDisplayText {
|
|
nsView.stringValue = desiredDisplayText
|
|
}
|
|
|
|
if let window = nsView.window {
|
|
let firstResponder = window.firstResponder
|
|
let isFirstResponder =
|
|
firstResponder === nsView ||
|
|
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
|
|
if isFocused, !isFirstResponder {
|
|
window.makeFirstResponder(nsView)
|
|
} else if !isFocused, isFirstResponder {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
}
|
|
|
|
if let editor = nsView.currentEditor() as? NSTextView {
|
|
if let inlineCompletion {
|
|
let currentSelection = editor.selectedRange()
|
|
let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion(
|
|
currentSelection: currentSelection,
|
|
inlineCompletion: inlineCompletion
|
|
)
|
|
if context.coordinator.appliedInlineCompletion != inlineCompletion ||
|
|
!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 = inlineCompletion
|
|
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
|
|
|
|
// Keep radii below the smallest rendered heights so corners don't get
|
|
// auto-clamped and visually change as popup height changes.
|
|
private let popupCornerRadius: CGFloat = 16
|
|
private let rowHighlightCornerRadius: CGFloat = 12
|
|
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
|
|
}
|
|
|
|
@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(Color.white.opacity(0.9))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if let badge = item.trailingBadgeText {
|
|
Text(badge)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(Color.white.opacity(0.72))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
.fill(Color.white.opacity(0.08))
|
|
)
|
|
}
|
|
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
|
|
? Color.white.opacity(0.12)
|
|
: 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: [
|
|
Color.black.opacity(0.26),
|
|
Color.black.opacity(0.14),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.white.opacity(0.22),
|
|
Color.white.opacity(0.06),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
|
|
.contentShape(Rectangle())
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityRespondsToUserInteraction(true)
|
|
.accessibilityIdentifier("BrowserOmnibarSuggestions")
|
|
.accessibilityLabel("Address bar suggestions")
|
|
}
|
|
}
|
|
|
|
/// NSViewRepresentable wrapper for WKWebView
|
|
struct WebViewRepresentable: NSViewRepresentable {
|
|
let panel: BrowserPanel
|
|
let shouldAttachWebView: Bool
|
|
let shouldFocusWebView: Bool
|
|
|
|
final class Coordinator {
|
|
weak var webView: WKWebView?
|
|
var constraints: [NSLayoutConstraint] = []
|
|
var attachRetryWorkItem: DispatchWorkItem?
|
|
var attachRetryCount: Int = 0
|
|
var attachGeneration: Int = 0
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let container = NSView()
|
|
container.wantsLayer = true
|
|
return container
|
|
}
|
|
|
|
private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) {
|
|
// WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder
|
|
// while being detached/reparented during bonsplit/SwiftUI structural updates.
|
|
if let window = webView.window,
|
|
responderChainContains(window.firstResponder, target: webView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
|
|
// Detach from any previous host (bonsplit/SwiftUI may rearrange views).
|
|
webView.removeFromSuperview()
|
|
host.subviews.forEach { $0.removeFromSuperview() }
|
|
host.addSubview(webView)
|
|
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.deactivate(coordinator.constraints)
|
|
coordinator.constraints = [
|
|
webView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
|
webView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
webView.topAnchor.constraint(equalTo: host.topAnchor),
|
|
webView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
|
]
|
|
NSLayoutConstraint.activate(coordinator.constraints)
|
|
|
|
// Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out.
|
|
webView.needsLayout = true
|
|
webView.layoutSubtreeIfNeeded()
|
|
webView.needsDisplay = true
|
|
webView.displayIfNeeded()
|
|
}
|
|
|
|
private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) {
|
|
// Don't schedule multiple overlapping retries.
|
|
guard coordinator.attachRetryWorkItem == nil else { return }
|
|
|
|
let work = DispatchWorkItem { [weak host, weak webView] in
|
|
coordinator.attachRetryWorkItem = nil
|
|
guard let host, let webView else { return }
|
|
guard coordinator.attachGeneration == generation else { return }
|
|
|
|
// If already attached, we're done.
|
|
if webView.superview === host {
|
|
coordinator.attachRetryCount = 0
|
|
return
|
|
}
|
|
|
|
// Wait until the host is actually in a window. SwiftUI can create a new container before it
|
|
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
|
|
guard host.window != nil else {
|
|
coordinator.attachRetryCount += 1
|
|
// Be generous here: bonsplit structural updates can keep a representable
|
|
// container off-window longer than a few seconds under load.
|
|
if coordinator.attachRetryCount < 400 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
coordinator.attachRetryCount = 0
|
|
attachWebView(webView, to: host, coordinator: coordinator)
|
|
}
|
|
|
|
coordinator.attachRetryWorkItem = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
let webView = panel.webView
|
|
context.coordinator.webView = webView
|
|
|
|
// Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left
|
|
// in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce
|
|
// WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane.
|
|
if !shouldAttachWebView {
|
|
context.coordinator.attachRetryWorkItem?.cancel()
|
|
context.coordinator.attachRetryWorkItem = nil
|
|
context.coordinator.attachRetryCount = 0
|
|
context.coordinator.attachGeneration += 1
|
|
|
|
// Resign focus if WebKit currently owns first responder.
|
|
if let window = webView.window,
|
|
Self.responderChainContains(window.firstResponder, target: webView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
|
|
NSLayoutConstraint.deactivate(context.coordinator.constraints)
|
|
context.coordinator.constraints.removeAll()
|
|
|
|
if webView.superview != nil {
|
|
webView.removeFromSuperview()
|
|
}
|
|
nsView.subviews.forEach { $0.removeFromSuperview() }
|
|
return
|
|
}
|
|
|
|
if webView.superview !== nsView {
|
|
// Cancel any pending retry; we'll reschedule if needed.
|
|
context.coordinator.attachRetryWorkItem?.cancel()
|
|
context.coordinator.attachRetryWorkItem = nil
|
|
context.coordinator.attachGeneration += 1
|
|
|
|
if nsView.window == nil {
|
|
// Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI
|
|
// can create containers that are never inserted into the window.
|
|
Self.scheduleAttachRetry(
|
|
webView,
|
|
to: nsView,
|
|
coordinator: context.coordinator,
|
|
generation: context.coordinator.attachGeneration
|
|
)
|
|
} else {
|
|
Self.attachWebView(webView, to: nsView, coordinator: context.coordinator)
|
|
}
|
|
} else {
|
|
// Already attached; no need for any pending retry.
|
|
context.coordinator.attachRetryWorkItem?.cancel()
|
|
context.coordinator.attachRetryWorkItem = nil
|
|
context.coordinator.attachRetryCount = 0
|
|
context.coordinator.attachGeneration += 1
|
|
}
|
|
|
|
// 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 Self.responderChainContains(window.firstResponder, target: webView) {
|
|
return
|
|
}
|
|
window.makeFirstResponder(webView)
|
|
} else {
|
|
if Self.responderChainContains(window.firstResponder, target: webView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
|
coordinator.attachRetryWorkItem?.cancel()
|
|
coordinator.attachRetryWorkItem = nil
|
|
coordinator.attachRetryCount = 0
|
|
coordinator.attachGeneration += 1
|
|
|
|
NSLayoutConstraint.deactivate(coordinator.constraints)
|
|
coordinator.constraints.removeAll()
|
|
|
|
guard let webView = coordinator.webView else { return }
|
|
|
|
// If we're being torn down while the WKWebView (or one of its subviews) is first responder,
|
|
// resign it before detaching.
|
|
let window = webView.window ?? nsView.window
|
|
if let window, responderChainContains(window.firstResponder, target: webView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
if webView.superview === nsView {
|
|
webView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|