From 586da4e6106bca3235cfaacd5f21628e40346898 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Thu, 16 Apr 2026 09:36:33 +0200 Subject: [PATCH] LibWebView: Add a "Search with " autocomplete row Synthesize an extra AutocompleteSuggestion at the top of the Search Suggestions section whenever there is a configured search engine and the typed query is not URL-shaped. Use the query as the row's primary text, carry a "Search with " subtitle, and render that subtitle in the AppKit and Qt popups so the explicit search fallback stays visible and readable even when history fills the list. --- Libraries/LibWebView/Autocomplete.cpp | 57 ++++++++++++++++++++++----- Libraries/LibWebView/Autocomplete.h | 1 + UI/AppKit/Interface/Autocomplete.mm | 3 +- UI/Qt/Autocomplete.cpp | 11 +++++- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Libraries/LibWebView/Autocomplete.cpp b/Libraries/LibWebView/Autocomplete.cpp index b4604450a7..d52bcbb212 100644 --- a/Libraries/LibWebView/Autocomplete.cpp +++ b/Libraries/LibWebView/Autocomplete.cpp @@ -94,6 +94,7 @@ static Vector make_history_suggestions(Vector make_history_suggestions(Vector search_for_query_suggestion(StringView query) +{ + if (query.is_empty() || location_looks_like_url(query)) + return {}; + + auto const& search_engine = Application::settings().search_engine(); + if (!search_engine.has_value()) + return {}; + + auto query_string = MUST(String::from_utf8(query)); + auto subtitle = MUST(String::formatted("Search with {}", search_engine->name)); + + return AutocompleteSuggestion { + .source = AutocompleteSuggestionSource::Search, + .section = AutocompleteSuggestionSection::SearchSuggestions, + .text = query_string, + .title = query_string, + .subtitle = move(subtitle), + .favicon_base64_png = {}, + }; +} + static Optional literal_url_suggestion(StringView query) { if (query.is_empty() || !location_looks_like_url(query)) @@ -111,6 +134,7 @@ static Optional literal_url_suggestion(StringView query) .section = AutocompleteSuggestionSection::None, .text = MUST(String::from_utf8(query)), .title = {}, + .subtitle = {}, .favicon_base64_png = {}, }; } @@ -170,19 +194,32 @@ static void append_suggestion_if_unique(Vector& suggesti } static Vector merge_suggestions( + Optional search_for_query_suggestion, Optional literal_url_suggestion, Vector history_suggestions, Vector remote_suggestions, size_t max_suggestions) { Vector suggestions; - suggestions.ensure_capacity(min(max_suggestions, history_suggestions.size() + remote_suggestions.size() + (literal_url_suggestion.has_value() ? 1 : 0))); + suggestions.ensure_capacity(min(max_suggestions, history_suggestions.size() + remote_suggestions.size() + (literal_url_suggestion.has_value() ? 1 : 0) + (search_for_query_suggestion.has_value() ? 1 : 0))); + + // Reserve a slot for the synthesized "Search with " row so it + // is always visible, even when history results would otherwise fill the + // popup. Without this, a query with plenty of history hits leaves the + // user with no explicit search fallback. + auto reserved_for_search = search_for_query_suggestion.has_value() ? 1u : 0u; + auto history_and_url_cap = reserved_for_search < max_suggestions + ? max_suggestions - reserved_for_search + : max_suggestions; if (literal_url_suggestion.has_value()) - append_suggestion_if_unique(suggestions, max_suggestions, literal_url_suggestion.release_value()); + append_suggestion_if_unique(suggestions, history_and_url_cap, literal_url_suggestion.release_value()); for (auto& suggestion : history_suggestions) - append_suggestion_if_unique(suggestions, max_suggestions, move(suggestion)); + append_suggestion_if_unique(suggestions, history_and_url_cap, move(suggestion)); + + if (search_for_query_suggestion.has_value()) + append_suggestion_if_unique(suggestions, max_suggestions, search_for_query_suggestion.release_value()); for (auto& suggestion : remote_suggestions) { auto remote_suggestion = AutocompleteSuggestion { @@ -190,6 +227,7 @@ static Vector merge_suggestions( .section = AutocompleteSuggestionSection::SearchSuggestions, .text = move(suggestion), .title = {}, + .subtitle = {}, .favicon_base64_png = {}, }; append_suggestion_if_unique(suggestions, max_suggestions, move(remote_suggestion)); @@ -222,10 +260,11 @@ void Autocomplete::query_autocomplete_engine(String query, size_t max_suggestion m_history_suggestions = make_history_suggestions(Application::history_store().autocomplete_entries(trimmed_query, m_max_suggestions)); auto literal_suggestion = preferred_literal_url_suggestion(trimmed_query, m_history_suggestions); + auto search_suggestion = search_for_query_suggestion(trimmed_query); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete suggestions for '{}': {}", trimmed_query, log_autocomplete_suggestions(m_history_suggestions)); - auto immediate_suggestions = merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions); + auto immediate_suggestions = merge_suggestions(search_suggestion, literal_suggestion, m_history_suggestions, {}, m_max_suggestions); if (trimmed_query.is_empty()) { invoke_autocomplete_query_complete(move(immediate_suggestions), AutocompleteResultKind::Final); @@ -265,7 +304,7 @@ void Autocomplete::query_autocomplete_engine(String query, size_t max_suggestion m_request = Application::request_server_client().start_request("GET"sv, *url); m_request->set_buffered_request_finished_callback( - [this, engine = engine.release_value(), query = m_query, literal_suggestion](u64, Requests::RequestTimingInfo const&, Optional const& network_error, HTTP::HeaderList const& response_headers, Optional response_code, Optional const& reason_phrase, ReadonlyBytes payload) { + [this, engine = engine.release_value(), query = m_query, literal_suggestion, search_suggestion](u64, Requests::RequestTimingInfo const&, Optional const& network_error, HTTP::HeaderList const& response_headers, Optional response_code, Optional const& reason_phrase, ReadonlyBytes payload) { Core::deferred_invoke([this]() { m_request.clear(); }); if (m_query != query) { @@ -275,12 +314,12 @@ void Autocomplete::query_autocomplete_engine(String query, size_t max_suggestion if (network_error.has_value()) { warnln("Unable to fetch autocomplete suggestions: {}", Requests::network_error_to_string(*network_error)); - invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); + invoke_autocomplete_query_complete(merge_suggestions(search_suggestion, literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); return; } if (response_code.has_value() && *response_code >= 400) { warnln("Received error response code {} from autocomplete engine: {}", *response_code, reason_phrase); - invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); + invoke_autocomplete_query_complete(merge_suggestions(search_suggestion, literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); return; } @@ -288,13 +327,13 @@ void Autocomplete::query_autocomplete_engine(String query, size_t max_suggestion if (auto result = received_autocomplete_respsonse(engine, content_type, payload); result.is_error()) { warnln("Unable to handle autocomplete response: {}", result.error()); - invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); + invoke_autocomplete_query_complete(merge_suggestions(search_suggestion, literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); } else { auto remote_suggestions = result.release_value(); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Remote autocomplete suggestions for '{}': {}", query, history_log_suggestions(remote_suggestions)); - auto merged_suggestions = merge_suggestions(literal_suggestion, m_history_suggestions, move(remote_suggestions), m_max_suggestions); + auto merged_suggestions = merge_suggestions(search_suggestion, literal_suggestion, m_history_suggestions, move(remote_suggestions), m_max_suggestions); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Merged autocomplete suggestions for '{}': {}", query, log_autocomplete_suggestions(merged_suggestions)); diff --git a/Libraries/LibWebView/Autocomplete.h b/Libraries/LibWebView/Autocomplete.h index c1bc71800d..fcd66f4356 100644 --- a/Libraries/LibWebView/Autocomplete.h +++ b/Libraries/LibWebView/Autocomplete.h @@ -48,6 +48,7 @@ struct WEBVIEW_API AutocompleteSuggestion { AutocompleteSuggestionSection section { AutocompleteSuggestionSection::None }; String text; Optional title; + Optional subtitle; Optional favicon_base64_png; }; diff --git a/UI/AppKit/Interface/Autocomplete.mm b/UI/AppKit/Interface/Autocomplete.mm index 408db8e630..6edcfe985d 100644 --- a/UI/AppKit/Interface/Autocomplete.mm +++ b/UI/AppKit/Interface/Autocomplete.mm @@ -696,6 +696,7 @@ static NSImage* literal_url_suggestion_icon() auto const& suggestion = m_suggestions[row_model.suggestion_index]; auto* suggestion_text = Ladybird::string_to_ns_string(suggestion.text); auto* title_text = suggestion.title.has_value() ? Ladybird::string_to_ns_string(*suggestion.title) : nil; + auto* secondary_text = suggestion.subtitle.has_value() ? Ladybird::string_to_ns_string(*suggestion.subtitle) : suggestion_text; auto* favicon = [self.suggestion_icons objectForKey:suggestion_text]; auto* icon = suggestion.source == WebView::AutocompleteSuggestionSource::LiteralURL ? literal_url_suggestion_icon() @@ -750,7 +751,7 @@ static NSImage* literal_url_suggestion_icon() primary_text_height)]; } - [view.url_text_field setStringValue:suggestion_text]; + [view.url_text_field setStringValue:(title_text != nil ? secondary_text : suggestion_text)]; return view; } diff --git a/UI/Qt/Autocomplete.cpp b/UI/Qt/Autocomplete.cpp index 1d233d2c2c..b67ff6e801 100644 --- a/UI/Qt/Autocomplete.cpp +++ b/UI/Qt/Autocomplete.cpp @@ -46,6 +46,7 @@ enum AutocompleteRole { RowKindRole = Qt::UserRole + 1, HeaderTextRole, TitleRole, + SubtitleRole, UrlRole, FaviconRole, SourceRole, @@ -174,6 +175,10 @@ public: if (suggestion.title.has_value()) return qstring_from_ak_string(*suggestion.title); return {}; + case SubtitleRole: + if (suggestion.subtitle.has_value()) + return qstring_from_ak_string(*suggestion.subtitle); + return {}; case FaviconRole: for (auto const& entry : m_favicon_cache) { if (entry.suggestion_index == row.suggestion_index) @@ -288,6 +293,8 @@ public: auto url_text = index.data(UrlRole).toString(); auto title_text = index.data(TitleRole).toString(); + auto subtitle_text = index.data(SubtitleRole).toString(); + auto secondary_text = subtitle_text.isEmpty() ? url_text : subtitle_text; int icon_x = option.rect.left() + CELL_HORIZONTAL_PADDING; int icon_y = option.rect.top() + (option.rect.height() - CELL_ICON_SIZE) / 2; @@ -321,11 +328,11 @@ public: painter->setFont(autocomplete_secondary_font()); painter->setPen(option.palette.color(QPalette::Disabled, QPalette::Text)); - auto elided_url = secondary_fm.elidedText(url_text, Qt::ElideRight, text_width); + auto elided_secondary = secondary_fm.elidedText(secondary_text, Qt::ElideRight, text_width); painter->drawText( QRect(text_x, block_y + primary_fm.height() + CELL_LABEL_VERTICAL_SPACING, text_width, secondary_fm.height()), - Qt::AlignLeft | Qt::AlignVCenter, elided_url); + Qt::AlignLeft | Qt::AlignVCenter, elided_secondary); } else { painter->setFont(QApplication::font()); painter->setPen(option.palette.color(QPalette::Text));