mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-05-13 18:36:38 +02:00
Previously, if search was disabled, entering non-URL text would just silently drop the search query (and on Qt, we would reload the current URL). We now detect that the query did not result in a navigation and load an error page instead, which directs the user to enable search.
528 lines
18 KiB
C++
528 lines
18 KiB
C++
/*
|
|
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
|
|
* Copyright (c) 2026-present, the Ladybird developers.
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <AK/Debug.h>
|
|
#include <LibURL/URL.h>
|
|
#include <LibWebView/Application.h>
|
|
#include <LibWebView/Autocomplete.h>
|
|
#include <LibWebView/URL.h>
|
|
#include <UI/Qt/Autocomplete.h>
|
|
#include <UI/Qt/LocationEdit.h>
|
|
#include <UI/Qt/StringUtils.h>
|
|
|
|
#include <QApplication>
|
|
#include <QKeyEvent>
|
|
#include <QLatin1String>
|
|
#include <QPalette>
|
|
#include <QTextLayout>
|
|
#include <QTimer>
|
|
|
|
namespace Ladybird {
|
|
|
|
static QString candidate_by_trimming_root_trailing_slash(QString const& candidate)
|
|
{
|
|
if (!candidate.endsWith(QLatin1Char('/')))
|
|
return candidate;
|
|
|
|
QString host_and_path = candidate;
|
|
for (auto scheme : { QLatin1String("https://"), QLatin1String("http://") }) {
|
|
if (host_and_path.startsWith(scheme)) {
|
|
host_and_path = host_and_path.mid(scheme.size());
|
|
break;
|
|
}
|
|
}
|
|
|
|
int first_slash = host_and_path.indexOf(QLatin1Char('/'));
|
|
if (first_slash == -1 || first_slash != host_and_path.length() - 1)
|
|
return candidate;
|
|
|
|
return candidate.left(candidate.length() - 1);
|
|
}
|
|
|
|
static bool query_matches_candidate_exactly(QString const& query, QString const& candidate)
|
|
{
|
|
auto trimmed = candidate_by_trimming_root_trailing_slash(candidate);
|
|
return trimmed.compare(query, Qt::CaseInsensitive) == 0;
|
|
}
|
|
|
|
static QString inline_autocomplete_text_for_candidate(QString const& query, QString const& candidate)
|
|
{
|
|
if (query.isEmpty() || candidate.length() <= query.length())
|
|
return {};
|
|
if (!candidate.startsWith(query, Qt::CaseInsensitive))
|
|
return {};
|
|
return query + candidate.mid(query.length());
|
|
}
|
|
|
|
static QString inline_autocomplete_text_for_suggestion(QString const& query, QString const& suggestion_text)
|
|
{
|
|
auto trimmed = candidate_by_trimming_root_trailing_slash(suggestion_text);
|
|
|
|
if (auto direct = inline_autocomplete_text_for_candidate(query, trimmed); !direct.isEmpty())
|
|
return direct;
|
|
|
|
if (trimmed.startsWith(QLatin1String("www."))) {
|
|
auto stripped = trimmed.mid(4);
|
|
if (auto match = inline_autocomplete_text_for_candidate(query, stripped); !match.isEmpty())
|
|
return match;
|
|
}
|
|
|
|
for (auto scheme : { QLatin1String("https://"), QLatin1String("http://") }) {
|
|
if (!trimmed.startsWith(scheme))
|
|
continue;
|
|
auto stripped = trimmed.mid(scheme.size());
|
|
if (auto match = inline_autocomplete_text_for_candidate(query, stripped); !match.isEmpty())
|
|
return match;
|
|
if (stripped.startsWith(QLatin1String("www."))) {
|
|
auto stripped_www = stripped.mid(4);
|
|
if (auto match = inline_autocomplete_text_for_candidate(query, stripped_www); !match.isEmpty())
|
|
return match;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
static bool suggestion_matches_query_exactly(QString const& query, QString const& suggestion_text)
|
|
{
|
|
auto trimmed = candidate_by_trimming_root_trailing_slash(suggestion_text);
|
|
if (query_matches_candidate_exactly(query, trimmed))
|
|
return true;
|
|
|
|
if (trimmed.startsWith(QLatin1String("www."))) {
|
|
if (query_matches_candidate_exactly(query, trimmed.mid(4)))
|
|
return true;
|
|
}
|
|
|
|
for (auto scheme : { QLatin1String("https://"), QLatin1String("http://") }) {
|
|
if (!trimmed.startsWith(scheme))
|
|
continue;
|
|
auto stripped = trimmed.mid(scheme.size());
|
|
if (query_matches_candidate_exactly(query, stripped))
|
|
return true;
|
|
if (stripped.startsWith(QLatin1String("www."))
|
|
&& query_matches_candidate_exactly(query, stripped.mid(4)))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static int autocomplete_suggestion_index(QString const& suggestion_text, Vector<WebView::AutocompleteSuggestion> const& suggestions)
|
|
{
|
|
for (size_t i = 0; i < suggestions.size(); ++i) {
|
|
if (qstring_from_ak_string(suggestions[i].text) == suggestion_text)
|
|
return static_cast<int>(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static bool should_suppress_inline_autocomplete_for_key(QKeyEvent const* event)
|
|
{
|
|
auto key = event->key();
|
|
return key == Qt::Key_Backspace || key == Qt::Key_Delete;
|
|
}
|
|
|
|
LocationEdit::LocationEdit(QWidget* parent)
|
|
: QLineEdit(parent)
|
|
, m_autocomplete(new Autocomplete(this))
|
|
{
|
|
update_placeholder();
|
|
|
|
m_autocomplete->on_query_complete = [this](auto suggestions, WebView::AutocompleteResultKind result_kind) {
|
|
int selected_row = apply_inline_autocomplete(suggestions);
|
|
|
|
if (result_kind == WebView::AutocompleteResultKind::Intermediate && m_autocomplete->is_visible()) {
|
|
if (auto selected = m_autocomplete->selected_suggestion(); selected.has_value()) {
|
|
for (auto const& suggestion : suggestions) {
|
|
if (suggestion.text == *selected)
|
|
return;
|
|
}
|
|
}
|
|
m_autocomplete->clear_selection();
|
|
return;
|
|
}
|
|
|
|
m_autocomplete->show_with_suggestions(AK::move(suggestions), selected_row);
|
|
};
|
|
|
|
connect(m_autocomplete, &Autocomplete::suggestion_activated, this, [this](QString const& text) {
|
|
m_is_applying_inline_autocomplete = true;
|
|
setText(text);
|
|
m_is_applying_inline_autocomplete = false;
|
|
m_autocomplete->close();
|
|
emit returnPressed();
|
|
});
|
|
|
|
connect(m_autocomplete, &Autocomplete::suggestion_highlighted, this, [this](QString const& text) {
|
|
auto query = current_query();
|
|
apply_inline_autocomplete_suggestion_text(text, query);
|
|
});
|
|
|
|
connect(m_autocomplete, &Autocomplete::did_close, this, [this] {
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
restore_query();
|
|
});
|
|
|
|
connect(this, &QLineEdit::returnPressed, this, [this] {
|
|
if (text().isEmpty())
|
|
return;
|
|
|
|
reset_autocomplete_state();
|
|
clearFocus();
|
|
|
|
auto query = ak_string_from_qstring(text());
|
|
|
|
auto ctrl_held = QApplication::keyboardModifiers() & Qt::ControlModifier;
|
|
auto append_tld = ctrl_held ? WebView::AppendTLD::Yes : WebView::AppendTLD::No;
|
|
|
|
auto url = WebView::sanitize_url(query, WebView::Application::settings().search_engine(), append_tld);
|
|
set_url(AK::move(url));
|
|
});
|
|
|
|
connect(this, &QLineEdit::textEdited, this, [this] {
|
|
if (m_is_applying_inline_autocomplete)
|
|
return;
|
|
|
|
auto query = current_query();
|
|
|
|
if (m_should_suppress_inline_autocomplete_on_next_change) {
|
|
m_suppressed_inline_autocomplete_query = query;
|
|
m_should_suppress_inline_autocomplete_on_next_change = false;
|
|
} else if (!m_suppressed_inline_autocomplete_query.isNull()
|
|
&& m_suppressed_inline_autocomplete_query != query) {
|
|
m_suppressed_inline_autocomplete_query = QString();
|
|
}
|
|
|
|
if (m_suppressed_inline_autocomplete_query.isNull()
|
|
&& !m_current_inline_autocomplete_suggestion.isEmpty()) {
|
|
if (!apply_inline_autocomplete_suggestion_text(m_current_inline_autocomplete_suggestion, query))
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
}
|
|
|
|
m_autocomplete->query_autocomplete_engine(ak_string_from_qstring(query));
|
|
});
|
|
|
|
connect(this, &QLineEdit::textChanged, this, &LocationEdit::highlight_location);
|
|
}
|
|
|
|
void LocationEdit::focusInEvent(QFocusEvent* event)
|
|
{
|
|
QLineEdit::focusInEvent(event);
|
|
highlight_location();
|
|
|
|
if (event->reason() != Qt::PopupFocusReason)
|
|
QTimer::singleShot(0, this, &QLineEdit::selectAll);
|
|
}
|
|
|
|
void LocationEdit::focusOutEvent(QFocusEvent* event)
|
|
{
|
|
QLineEdit::focusOutEvent(event);
|
|
|
|
reset_autocomplete_state();
|
|
m_autocomplete->cancel_pending_query();
|
|
m_autocomplete->close();
|
|
|
|
if (m_url_is_hidden) {
|
|
m_url_is_hidden = false;
|
|
if (text().isEmpty() && m_url.has_value())
|
|
setText(qstring_from_ak_string(m_url->serialize()));
|
|
}
|
|
|
|
if (event->reason() != Qt::PopupFocusReason) {
|
|
setCursorPosition(0);
|
|
highlight_location();
|
|
}
|
|
}
|
|
|
|
void LocationEdit::keyPressEvent(QKeyEvent* event)
|
|
{
|
|
if (event->key() == Qt::Key_Escape) {
|
|
if (m_autocomplete->close())
|
|
return;
|
|
reset_autocomplete_state();
|
|
if (m_url.has_value())
|
|
setText(qstring_from_ak_string(m_url->serialize()));
|
|
clearFocus();
|
|
return;
|
|
}
|
|
|
|
if (event->key() == Qt::Key_Down) {
|
|
if (m_autocomplete->select_next_suggestion())
|
|
return;
|
|
}
|
|
|
|
if (event->key() == Qt::Key_Up) {
|
|
if (m_autocomplete->select_previous_suggestion())
|
|
return;
|
|
}
|
|
|
|
if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && m_autocomplete->is_visible()) {
|
|
if (auto selected = m_autocomplete->selected_suggestion(); selected.has_value()) {
|
|
m_is_applying_inline_autocomplete = true;
|
|
setText(qstring_from_ak_string(*selected));
|
|
m_is_applying_inline_autocomplete = false;
|
|
}
|
|
m_autocomplete->close();
|
|
}
|
|
|
|
if (should_suppress_inline_autocomplete_for_key(event))
|
|
m_should_suppress_inline_autocomplete_on_next_change = true;
|
|
|
|
QLineEdit::keyPressEvent(event);
|
|
}
|
|
|
|
void LocationEdit::search_engine_changed()
|
|
{
|
|
update_placeholder();
|
|
}
|
|
|
|
void LocationEdit::update_placeholder()
|
|
{
|
|
if (auto const& search_engine = WebView::Application::settings().search_engine(); search_engine.has_value()) {
|
|
auto prompt = MUST(String::formatted("Search with {} or enter web address", search_engine->name));
|
|
setPlaceholderText(qstring_from_ak_string(prompt));
|
|
} else {
|
|
setPlaceholderText("Enter web address");
|
|
}
|
|
}
|
|
|
|
void LocationEdit::highlight_location()
|
|
{
|
|
auto url = ak_string_from_qstring(text());
|
|
QList<QInputMethodEvent::Attribute> attributes;
|
|
|
|
if (auto url_parts = WebView::break_url_into_parts(url); url_parts.has_value()) {
|
|
auto darkened_text_color = QPalette().color(QPalette::Text);
|
|
darkened_text_color.setAlpha(127);
|
|
|
|
QTextCharFormat dark_attributes;
|
|
dark_attributes.setForeground(darkened_text_color);
|
|
|
|
QTextCharFormat highlight_attributes;
|
|
highlight_attributes.setForeground(QPalette().color(QPalette::Text));
|
|
|
|
attributes.append({
|
|
QInputMethodEvent::TextFormat,
|
|
-cursorPosition(),
|
|
static_cast<int>(url_parts->scheme_and_subdomain.length()),
|
|
dark_attributes,
|
|
});
|
|
|
|
attributes.append({
|
|
QInputMethodEvent::TextFormat,
|
|
static_cast<int>(url_parts->scheme_and_subdomain.length() - cursorPosition()),
|
|
static_cast<int>(url_parts->effective_tld_plus_one.length()),
|
|
highlight_attributes,
|
|
});
|
|
|
|
attributes.append({
|
|
QInputMethodEvent::TextFormat,
|
|
static_cast<int>(url_parts->scheme_and_subdomain.length() + url_parts->effective_tld_plus_one.length() - cursorPosition()),
|
|
static_cast<int>(url_parts->remainder.length()),
|
|
dark_attributes,
|
|
});
|
|
}
|
|
|
|
QInputMethodEvent event(QString(), attributes);
|
|
QCoreApplication::sendEvent(this, &event);
|
|
}
|
|
|
|
void LocationEdit::set_url(Optional<URL::URL> url)
|
|
{
|
|
m_url = AK::move(url);
|
|
|
|
if (m_url_is_hidden) {
|
|
clear();
|
|
} else if (m_url.has_value()) {
|
|
setText(qstring_from_ak_string(m_url->serialize()));
|
|
setCursorPosition(0);
|
|
}
|
|
}
|
|
|
|
QString LocationEdit::current_query() const
|
|
{
|
|
if (!hasSelectedText())
|
|
return text();
|
|
int start = selectionStart();
|
|
int length = selectedText().length();
|
|
if (start + length != text().length())
|
|
return text();
|
|
return text().left(start);
|
|
}
|
|
|
|
int LocationEdit::apply_inline_autocomplete(Vector<WebView::AutocompleteSuggestion> const& suggestions)
|
|
{
|
|
if (m_is_applying_inline_autocomplete) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: skipped (re-entrant)");
|
|
return -1;
|
|
}
|
|
if (!hasFocus()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: skipped (no focus)");
|
|
return -1;
|
|
}
|
|
|
|
QString query;
|
|
auto current_text = text();
|
|
if (!hasSelectedText()) {
|
|
if (cursorPosition() != current_text.length()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: skipped (caret not at end)");
|
|
return -1;
|
|
}
|
|
query = current_text;
|
|
} else {
|
|
int start = selectionStart();
|
|
int end = start + selectedText().length();
|
|
if (end != current_text.length()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: skipped (selection not at end)");
|
|
return -1;
|
|
}
|
|
query = current_text.left(start);
|
|
}
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: query='{}' suggestions={}",
|
|
ak_string_from_qstring(query),
|
|
suggestions.size());
|
|
for (size_t i = 0; i < suggestions.size(); ++i) {
|
|
auto const& suggestion = suggestions[i];
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] [{}] source={} text='{}'",
|
|
i,
|
|
suggestion.source == WebView::AutocompleteSuggestionSource::LiteralURL ? "LiteralURL"sv
|
|
: suggestion.source == WebView::AutocompleteSuggestionSource::History ? "History"sv
|
|
: "Search"sv,
|
|
suggestion.text);
|
|
}
|
|
|
|
if (suggestions.is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: no suggestions, selected=-1");
|
|
return -1;
|
|
}
|
|
|
|
// Row 0 drives both the visible highlight and (if its text prefix-matches
|
|
// the query) the inline completion preview. This is a deliberate
|
|
// simplification over the exact/inline/fallback fan-out we used to have:
|
|
// the user-visible rule is "the top row is the default action".
|
|
|
|
auto row_0_text_q = qstring_from_ak_string(suggestions.first().text);
|
|
|
|
// A literal URL always wins: no preview, restore the typed text.
|
|
if (suggestions.first().source == WebView::AutocompleteSuggestionSource::LiteralURL) {
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
if (hasSelectedText() || current_text != query)
|
|
restore_query();
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: literal URL, selected=0");
|
|
return 0;
|
|
}
|
|
|
|
// Backspace suppression: the user just deleted into this query, so don't
|
|
// re-apply an inline preview — but still honor the "highlight the top row"
|
|
// rule.
|
|
if (!m_suppressed_inline_autocomplete_query.isNull() && m_suppressed_inline_autocomplete_query == query) {
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
if (hasSelectedText() || current_text != query)
|
|
restore_query();
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: suppressed query, selected=0 (no preview)");
|
|
return 0;
|
|
}
|
|
|
|
// Preserve an existing inline preview if its row is still present and
|
|
// still extends the typed prefix. This keeps the preview stable while the
|
|
// user is still forward-typing into a suggestion.
|
|
if (!m_current_inline_autocomplete_suggestion.isEmpty()) {
|
|
int preserved = autocomplete_suggestion_index(m_current_inline_autocomplete_suggestion, suggestions);
|
|
if (preserved != -1) {
|
|
auto preserved_inline = inline_autocomplete_text_for_suggestion(query, m_current_inline_autocomplete_suggestion);
|
|
if (!preserved_inline.isEmpty()) {
|
|
apply_inline_autocomplete_text(preserved_inline, query);
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: preserved inline row={} text='{}'",
|
|
preserved, ak_string_from_qstring(m_current_inline_autocomplete_suggestion));
|
|
return preserved;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to inline-preview row 0 specifically.
|
|
auto row_0_inline = inline_autocomplete_text_for_suggestion(query, row_0_text_q);
|
|
if (!row_0_inline.isEmpty()) {
|
|
m_current_inline_autocomplete_suggestion = row_0_text_q;
|
|
apply_inline_autocomplete_text(row_0_inline, query);
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: row 0 inline match, inline='{}'",
|
|
ak_string_from_qstring(row_0_inline));
|
|
return 0;
|
|
}
|
|
|
|
// Row 0 does not prefix-match the query: clear any stale inline preview,
|
|
// restore the typed text, and still highlight row 0.
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
if (hasSelectedText() || current_text != query)
|
|
restore_query();
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] apply_inline_autocomplete: row 0 not a prefix match, selected=0 (highlight only)");
|
|
return 0;
|
|
}
|
|
|
|
bool LocationEdit::apply_inline_autocomplete_suggestion_text(QString const& suggestion_text, QString const& query)
|
|
{
|
|
if (suggestion_matches_query_exactly(query, suggestion_text)) {
|
|
restore_query();
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
return true;
|
|
}
|
|
|
|
auto inline_text = inline_autocomplete_text_for_suggestion(query, suggestion_text);
|
|
if (inline_text.isEmpty())
|
|
return false;
|
|
|
|
m_current_inline_autocomplete_suggestion = suggestion_text;
|
|
apply_inline_autocomplete_text(inline_text, query);
|
|
return true;
|
|
}
|
|
|
|
void LocationEdit::apply_inline_autocomplete_text(QString const& inline_text, QString const& query)
|
|
{
|
|
if (!hasFocus())
|
|
return;
|
|
|
|
int completion_start = query.length();
|
|
int completion_length = inline_text.length() - query.length();
|
|
if (completion_length <= 0)
|
|
return;
|
|
|
|
if (text() == inline_text && hasSelectedText()
|
|
&& selectionStart() == completion_start
|
|
&& selectedText().length() == completion_length)
|
|
return;
|
|
|
|
m_is_applying_inline_autocomplete = true;
|
|
setText(inline_text);
|
|
setSelection(completion_start, completion_length);
|
|
m_is_applying_inline_autocomplete = false;
|
|
}
|
|
|
|
void LocationEdit::restore_query()
|
|
{
|
|
if (!hasFocus())
|
|
return;
|
|
|
|
auto query = current_query();
|
|
if (text() == query && !hasSelectedText())
|
|
return;
|
|
|
|
m_is_applying_inline_autocomplete = true;
|
|
setText(query);
|
|
setCursorPosition(query.length());
|
|
m_is_applying_inline_autocomplete = false;
|
|
}
|
|
|
|
void LocationEdit::reset_autocomplete_state()
|
|
{
|
|
m_current_inline_autocomplete_suggestion.clear();
|
|
m_suppressed_inline_autocomplete_query = QString();
|
|
m_should_suppress_inline_autocomplete_on_next_change = false;
|
|
}
|
|
|
|
}
|