diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index debf230f..0fab49b5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -184,6 +184,18 @@ final class BrowserHistoryStore: ObservableObject { // Most-recent first. entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited }) + + // Remove entries with invalid hosts (no TLD), e.g. "https://news." + let beforeCount = entries.count + entries.removeAll { entry in + guard let url = URL(string: entry.url), + let host = url.host?.lowercased() else { return false } + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + return !trimmed.contains(".") + } + if entries.count != beforeCount { + scheduleSave() + } } func recordVisit(url: URL?, title: String?) { @@ -192,6 +204,11 @@ final class BrowserHistoryStore: ObservableObject { guard let url else { return } guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } + // Skip URLs whose host lacks a TLD (e.g. "https://news."). + if let host = url.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { return } + } let urlString = url.absoluteString guard urlString != "about:blank" else { return } @@ -232,6 +249,11 @@ final class BrowserHistoryStore: ObservableObject { guard let url else { return } guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } + // Skip URLs whose host lacks a TLD (e.g. "https://news."). + if let host = url.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { return } + } let urlString = url.absoluteString guard urlString != "about:blank" else { return } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 18a3a41f..588dbd7f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2,6 +2,18 @@ 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 @@ -9,13 +21,16 @@ struct BrowserPanelView: View { let isVisibleInUI: Bool let onRequestPanelFocus: () -> Void @State private var omnibarState = OmnibarState() - @FocusState private var addressBarFocused: Bool + @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? @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? @@ -48,190 +63,8 @@ struct BrowserPanelView: View { var body: some View { VStack(spacing: 0) { - // Address bar - HStack(spacing: 8) { - let navButtonSize: CGFloat = 22 - - // Back button - Button(action: { 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") - - // Forward button - Button(action: { 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") - - // Reload/Stop button - Button(action: { - if panel.isLoading { - panel.stopLoading() - } else { - 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") - - // URL TextField - HStack(spacing: 4) { - if panel.currentURL?.scheme == "https" { - Image(systemName: "lock.fill") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - - TextField( - "Search or enter URL", - text: Binding( - get: { omnibarState.buffer }, - set: { newValue in - let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(newValue)) - applyOmnibarEffects(effects) - } - ) - ) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .focused($addressBarFocused) - .accessibilityIdentifier("BrowserOmnibarTextField") - .simultaneousGesture(TapGesture().onEnded { - handleOmnibarTap() - }) - .onExitCommand { - // Chrome-style escape: - // - If editing / dropdown is open: revert to current URL, close dropdown, select all. - // - Otherwise: blur to the web view. - guard addressBarFocused else { return } - let effects = omnibarReduce(state: &omnibarState, event: .escape) - applyOmnibarEffects(effects) - } - .onSubmit { - if addressBarFocused, !omnibarState.suggestions.isEmpty { - commitSelectedSuggestion() - } else { - panel.navigateSmart(omnibarState.buffer) - hideSuggestions() - suppressNextFocusLostRevert = true - addressBarFocused = false - } - } - // XCUITest (and some SwiftUI/AppKit focus edge cases) can fail to trigger `onSubmit` - // reliably for TextField on macOS. Handle Return explicitly so Enter commits the - // selected suggestion (or navigates) like Chrome. - .backport.onKeyPress(.return) { _ in - guard addressBarFocused else { return .ignored } - if !omnibarState.suggestions.isEmpty { - commitSelectedSuggestion() - } else { - panel.navigateSmart(omnibarState.buffer) - hideSuggestions() - suppressNextFocusLostRevert = true - addressBarFocused = false - } - return .handled - } - .backport.onKeyPress(.downArrow) { _ in - guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored } - let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: +1)) - applyOmnibarEffects(effects) - return .handled - } - .backport.onKeyPress(.upArrow) { _ in - guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored } - let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: -1)) - applyOmnibarEffects(effects) - return .handled - } - .backport.onKeyPress("n") { modifiers in - // Emacs-style navigation: Ctrl+N / Ctrl+P. - // Also accept Cmd for users expecting Chrome-style shortcuts. - guard modifiers.contains(.control) || modifiers.contains(.command) else { return .ignored } - guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored } - let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: +1)) - applyOmnibarEffects(effects) - return .handled - } - .backport.onKeyPress("p") { modifiers in - guard modifiers.contains(.control) || modifiers.contains(.command) else { return .ignored } - guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored } - let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: -1)) - applyOmnibarEffects(effects) - return .handled - } - } - .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) - .accessibilityIdentifier("BrowserOmnibarPill") - .accessibilityLabel("Browser omnibar") - .background { - GeometryReader { geo in - Color.clear - .preference( - key: OmnibarPillFramePreferenceKey.self, - value: geo.frame(in: .named("BrowserPanelViewSpace")) - ) - } - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .windowBackgroundColor)) - // Keep the omnibar stack above WKWebView so the suggestions popup is visible. - .zIndex(1) - - // Web 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]) - } + addressBar + webView } .overlay { RoundedRectangle(cornerRadius: 10) @@ -283,7 +116,10 @@ struct BrowserPanelView: View { 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, addressWasEmpty, !isWebViewBlank() { + if addressBarFocused, + !panel.shouldSuppressWebViewFocus(), + addressWasEmpty, + !isWebViewBlank() { addressBarFocused = false } } @@ -297,8 +133,9 @@ struct BrowserPanelView: View { } } .onChange(of: addressBarFocused) { focused in - let urlString = panel.currentURL?.absoluteString ?? "" + 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. @@ -307,7 +144,9 @@ struct BrowserPanelView: View { } 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 @@ -317,10 +156,12 @@ struct BrowserPanelView: View { 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 @@ -329,7 +170,184 @@ struct BrowserPanelView: View { 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: { 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: { 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 { + panel.stopLoading() + } else { + 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() { @@ -350,7 +368,7 @@ struct BrowserPanelView: View { } private func syncURLFromPanel() { - let urlString = panel.currentURL?.absoluteString ?? "" + let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString)) applyOmnibarEffects(effects) } @@ -396,6 +414,7 @@ struct BrowserPanelView: View { let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([])) applyOmnibarEffects(effects) isLoadingRemoteSuggestions = false + inlineCompletion = nil } private func commitSelectedSuggestion() { @@ -408,12 +427,76 @@ struct BrowserPanelView: View { // 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 - panel.navigateSmart(suggestion.completion) + 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 @@ -426,31 +509,57 @@ struct BrowserPanelView: View { } let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { - let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([])) - applyOmnibarEffects(effects) - return - } - - let baseItems = localOmnibarSuggestions(for: query) - var items = baseItems - let staleRemote = staleRemoteSuggestionsForDisplay(query: query) - if !staleRemote.isEmpty { - items = mergeRemoteSuggestions(baseItems: items, remoteQueries: staleRemote) + 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() - if let forcedRemote = forcedRemoteSuggestionsForUITest() { + guard !query.isEmpty else { return } + + if !isSingleCharacterQuery, let forcedRemote = forcedRemoteSuggestionsForUITest() { latestRemoteSuggestionQuery = query latestRemoteSuggestions = forcedRemote - let merged = mergeRemoteSuggestions(baseItems: baseItems, remoteQueries: 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 @@ -465,38 +574,23 @@ struct BrowserPanelView: View { guard current == query else { return } latestRemoteSuggestionQuery = query latestRemoteSuggestions = remote - let merged = mergeRemoteSuggestions( - baseItems: localOmnibarSuggestions(for: query), - remoteQueries: 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 localOmnibarSuggestions(for query: String) -> [OmnibarSuggestion] { - var items: [OmnibarSuggestion] = [] - var seen = Set() - - func insert(_ item: OmnibarSuggestion) { - let key = item.completion.lowercased() - guard !seen.contains(key) else { return } - seen.insert(key) - items.append(item) - } - - insert(.search(engineName: searchEngine.displayName, query: query)) - - let history = BrowserHistoryStore.shared.suggestions(for: query, limit: 8) - for entry in history { - insert(.history(entry)) - } - - return items - } - private func staleRemoteSuggestionsForDisplay(query: String) -> [String] { staleOmnibarRemoteSuggestionsForDisplay( query: query, @@ -505,6 +599,111 @@ struct BrowserPanelView: View { ) } + private func matchingOpenTabSuggestions(for query: String, limit: Int) -> [OmnibarOpenTabMatch] { + guard !query.isEmpty, limit > 0 else { return [] } + + let loweredQuery = query.lowercased() + let singleCharacterQuery = omnibarSingleCharacterQuery(for: query) + let includeCurrentPanelForSingleCharacterQuery = singleCharacterQuery != nil + let tabManager = AppDelegate.shared?.tabManager + let currentPanelWorkspaceId = tabManager?.tabs.first(where: { tab in + tab.panels[panel.id] is BrowserPanel + })?.id + var matches: [OmnibarOpenTabMatch] = [] + var seenKeys = Set() + + func preferredPanelURL(_ browserPanel: BrowserPanel) -> String? { + browserPanel.preferredURLStringForOmnibar() + } + + func addMatch( + tabId: UUID, + panelId: UUID, + url: String, + title: String?, + isKnownOpenTab: Bool, + matches: inout [OmnibarOpenTabMatch], + seenKeys: inout Set + ) { + let key = "\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())" + guard !seenKeys.contains(key) else { return } + seenKeys.insert(key) + matches.append( + OmnibarOpenTabMatch( + tabId: tabId, + panelId: panelId, + url: url, + title: title, + isKnownOpenTab: isKnownOpenTab + ) + ) + } + + if includeCurrentPanelForSingleCharacterQuery, + let query = singleCharacterQuery, + let currentURL = preferredPanelURL(panel), + !currentURL.isEmpty { + let rawTitle = panel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let title = rawTitle.isEmpty ? nil : rawTitle + if omnibarHasSingleCharacterPrefixMatch(query: query, url: currentURL, title: title) { + addMatch( + tabId: currentPanelWorkspaceId ?? panel.workspaceId, + panelId: panel.id, + url: currentURL, + title: title, + isKnownOpenTab: currentPanelWorkspaceId != nil, + matches: &matches, + seenKeys: &seenKeys + ) + } + } + + guard let tabManager else { return matches } + + for tab in tabManager.tabs { + for (panelId, anyPanel) in tab.panels { + guard let browserPanel = anyPanel as? BrowserPanel else { continue } + guard let currentURL = preferredPanelURL(browserPanel), + !currentURL.isEmpty else { continue } + let isCurrentPanel = tab.id == panel.workspaceId && panelId == panel.id + if isCurrentPanel && !includeCurrentPanelForSingleCharacterQuery { + continue + } + + let rawTitle = browserPanel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let title = rawTitle.isEmpty ? nil : rawTitle + let isMatch: Bool = { + if let singleCharacterQuery { + return omnibarHasSingleCharacterPrefixMatch( + query: singleCharacterQuery, + url: currentURL, + title: title + ) + } + let haystacks = [ + currentURL.lowercased(), + (title ?? "").lowercased(), + ] + return haystacks.contains { $0.contains(loweredQuery) } + }() + guard isMatch else { continue } + + addMatch( + tabId: tab.id, + panelId: panelId, + url: currentURL, + title: title, + isKnownOpenTab: true, + matches: &matches, + seenKeys: &seenKeys + ) + } + } + + if matches.count <= limit { return matches } + return Array(matches.prefix(limit)) + } + private func forcedRemoteSuggestionsForUITest() -> [String]? { let raw = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] ?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") @@ -538,6 +737,7 @@ struct BrowserPanelView: View { 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) } @@ -545,20 +745,444 @@ struct BrowserPanelView: View { } } -func mergeRemoteSuggestions(baseItems: [OmnibarSuggestion], remoteQueries: [String], limit: Int = 8) -> [OmnibarSuggestion] { - var merged = baseItems - var mergedSeen = Set(merged.map { $0.completion.lowercased() }) - var insertionIndex = min(1, merged.count) - for s in remoteQueries.prefix(limit) { - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let key = trimmed.lowercased() - guard !mergedSeen.contains(key) else { continue } - mergedSeen.insert(key) - merged.insert(.remoteSearchSuggestion(trimmed), at: insertionIndex) - insertionIndex += 1 +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 + } } - return merged + +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( @@ -569,12 +1193,13 @@ func staleOmnibarRemoteSuggestionsForDisplay( ) -> [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 !previousRemoteSuggestions.isEmpty else { return [] } - // Keep stale rows only for nearby edits (typing/backspacing around the same query). - guard trimmedQuery.hasPrefix(trimmedPreviousQuery) || trimmedPreviousQuery.hasPrefix(trimmedQuery) else { + 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 } @@ -587,6 +1212,171 @@ func staleOmnibarRemoteSuggestionsForDisplay( 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 @@ -606,6 +1396,7 @@ struct OmnibarState: Equatable { var buffer: String = "" var suggestions: [OmnibarSuggestion] = [] var selectedSuggestionIndex: Int = 0 + var selectedSuggestionID: String? var isUserEditing: Bool = false } @@ -639,6 +1430,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil effects.shouldSelectAll = true case .focusLostRevertBuffer(let url): @@ -648,6 +1440,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil case .focusLostPreserveBuffer(let url): state.isFocused = false @@ -655,6 +1448,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.isUserEditing = false state.suggestions = [] state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil case .panelURLChanged(let url): state.currentURLString = url @@ -662,6 +1456,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.buffer = url state.suggestions = [] state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil } case .bufferChanged(let newValue): @@ -669,19 +1464,38 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff 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): @@ -690,10 +1504,12 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff 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 } @@ -705,6 +1521,7 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.buffer = state.currentURLString state.suggestions = [] state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil effects.shouldSelectAll = true } else { effects.shouldBlurToWebView = true @@ -717,7 +1534,9 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff 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) } @@ -728,8 +1547,12 @@ struct OmnibarSuggestion: Identifiable, Hashable { 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())" } @@ -738,7 +1561,9 @@ struct OmnibarSuggestion: Identifiable, Hashable { 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 } } @@ -747,34 +1572,478 @@ struct OmnibarSuggestion: Identifiable, Hashable { 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) ? (title ?? url) : url + 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 trimmedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmedTitle.isEmpty ? nil : url + 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) { + onPointerDown?() + super.mouseDown(with: event) + } + + 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 } + if !(window.firstResponder === field) { + 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 { @@ -790,10 +2059,10 @@ private struct OmnibarSuggestionsView: View { // auto-clamped and visually change as popup height changes. private let popupCornerRadius: CGFloat = 16 private let rowHighlightCornerRadius: CGFloat = 12 - private let rowHeight: CGFloat = 24 + private let singleLineRowHeight: CGFloat = 24 private let rowSpacing: CGFloat = 1 - private let topInset: CGFloat = 4 - private let bottomInset: CGFloat = 4 + private let topInset: CGFloat = 3 + private let bottomInset: CGFloat = 3 private var horizontalInset: CGFloat { topInset } private let maxPopupHeight: CGFloat = 560 @@ -801,14 +2070,20 @@ private struct OmnibarSuggestionsView: View { max(1, items.count) } + private func rowHeight(for item: OmnibarSuggestion) -> CGFloat { + return singleLineRowHeight + } + private var contentHeight: CGFloat { - let rows = CGFloat(totalRowCount) + let rowsHeight = items.isEmpty ? singleLineRowHeight : items.reduce(CGFloat(0)) { partial, item in + partial + rowHeight(for: item) + } let gaps = CGFloat(max(0, totalRowCount - 1)) - return (rows * rowHeight) + (gaps * rowSpacing) + topInset + bottomInset + return rowsHeight + (gaps * rowSpacing) + topInset + bottomInset } private var minimumPopupHeight: CGFloat { - rowHeight + topInset + bottomInset + singleLineRowHeight + topInset + bottomInset } private func snapToDevicePixels(_ value: CGFloat) -> CGFloat { @@ -840,18 +2115,35 @@ private struct OmnibarSuggestionsView: View { private var rowsView: some View { VStack(spacing: rowSpacing) { ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in - Button { - onCommit(item) - } label: { - HStack(spacing: 0) { - Text(item.primaryText) + Button { + 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, maxHeight: rowHeight, alignment: .leading) + .frame( + maxWidth: .infinity, + minHeight: rowHeight(for: item), + maxHeight: rowHeight(for: item), + alignment: .leading + ) .background( RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( @@ -863,7 +2155,11 @@ private struct OmnibarSuggestionsView: View { } .buttonStyle(.plain) .accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)") - .accessibilityValue(idx == selectedIndex ? "selected" : "") + .accessibilityValue( + idx == selectedIndex + ? "selected \(item.listText)" + : item.listText + ) .onHover { hovering in if hovering, idx != selectedIndex, isPointerDrivenSelectionEvent { onHighlight(idx) @@ -1100,6 +2396,9 @@ struct WebViewRepresentable: NSViewRepresentable { // 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 }