LibWebView: Add a "Search with <engine>" 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 <engine>"
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.
This commit is contained in:
Andreas Kling
2026-04-16 09:36:33 +02:00
committed by Andreas Kling
parent bb9f789eae
commit 586da4e610
Notes: github-actions[bot] 2026-04-16 19:02:36 +00:00
4 changed files with 60 additions and 12 deletions

View File

@@ -94,6 +94,7 @@ static Vector<AutocompleteSuggestion> make_history_suggestions(Vector<HistoryEnt
.section = AutocompleteSuggestionSection::History,
.text = move(entry.url),
.title = move(entry.title),
.subtitle = {},
.favicon_base64_png = move(entry.favicon_base64_png),
});
}
@@ -101,6 +102,28 @@ static Vector<AutocompleteSuggestion> make_history_suggestions(Vector<HistoryEnt
return suggestions;
}
static Optional<AutocompleteSuggestion> 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<AutocompleteSuggestion> literal_url_suggestion(StringView query)
{
if (query.is_empty() || !location_looks_like_url(query))
@@ -111,6 +134,7 @@ static Optional<AutocompleteSuggestion> 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<AutocompleteSuggestion>& suggesti
}
static Vector<AutocompleteSuggestion> merge_suggestions(
Optional<AutocompleteSuggestion> search_for_query_suggestion,
Optional<AutocompleteSuggestion> literal_url_suggestion,
Vector<AutocompleteSuggestion> history_suggestions,
Vector<String> remote_suggestions,
size_t max_suggestions)
{
Vector<AutocompleteSuggestion> 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 <engine>" 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<AutocompleteSuggestion> 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<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> const& reason_phrase, ReadonlyBytes payload) {
[this, engine = engine.release_value(), query = m_query, literal_suggestion, search_suggestion](u64, Requests::RequestTimingInfo const&, Optional<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> 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));

View File

@@ -48,6 +48,7 @@ struct WEBVIEW_API AutocompleteSuggestion {
AutocompleteSuggestionSection section { AutocompleteSuggestionSection::None };
String text;
Optional<String> title;
Optional<String> subtitle;
Optional<String> favicon_base64_png;
};

View File

@@ -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;
}

View File

@@ -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));