/* * Copyright (c) 2023, Cameron Youell * Copyright (c) 2026-present, the Ladybird developers. * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 const& suggestions) { for (size_t i = 0; i < suggestions.size(); ++i) { if (qstring_from_ak_string(suggestions[i].text) == suggestion_text) return static_cast(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 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(url_parts->scheme_and_subdomain.length()), dark_attributes, }); attributes.append({ QInputMethodEvent::TextFormat, static_cast(url_parts->scheme_and_subdomain.length() - cursorPosition()), static_cast(url_parts->effective_tld_plus_one.length()), highlight_attributes, }); attributes.append({ QInputMethodEvent::TextFormat, static_cast(url_parts->scheme_and_subdomain.length() + url_parts->effective_tld_plus_one.length() - cursorPosition()), static_cast(url_parts->remainder.length()), dark_attributes, }); } QInputMethodEvent event(QString(), attributes); QCoreApplication::sendEvent(this, &event); } void LocationEdit::set_url(Optional 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 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; } }