UI/Qt: Show rich, history-backed address bar autocomplete

Replace the old QCompleter-based popup with a custom list that renders
AutocompleteSuggestion rows with titles/URLs/section headers/favicons.

Parent the popup inside the window so it does not steal focus, route
keyboard and mouse activation through WebView::Autocomplete, and add
inline completion with backspace suppression and top-row selection.
This commit is contained in:
Andreas Kling 2026-04-16 09:36:03 +02:00 committed by Andreas Kling
parent ed34a4ba8a
commit bb9f789eae
6 changed files with 1012 additions and 38 deletions

BIN
UI/Icons/search.tvg Normal file

Binary file not shown.

View file

@ -1,43 +1,599 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Base64.h>
#include <LibWebView/Autocomplete.h>
#include <UI/Qt/Autocomplete.h>
#include <UI/Qt/Icon.h>
#include <UI/Qt/StringUtils.h>
#include <QAbstractListModel>
#include <QApplication>
#include <QEvent>
#include <QFontMetrics>
#include <QFrame>
#include <QIcon>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QMouseEvent>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPoint>
#include <QStyledItemDelegate>
#include <QVBoxLayout>
namespace Ladybird {
Autocomplete::Autocomplete(QWidget* parent)
: QCompleter(parent)
, m_autocomplete(make<WebView::Autocomplete>())
, m_model(new QStringListModel(this))
, m_popup(new QListView(parent))
{
m_autocomplete->on_autocomplete_query_complete = [this](auto const& suggestions) {
if (suggestions.is_empty()) {
m_model->setStringList({});
} else {
QStringList list;
for (auto const& suggestion : suggestions)
list.append(qstring_from_ak_string(suggestion));
static constexpr int POPUP_PADDING = 8;
static constexpr int CELL_HORIZONTAL_PADDING = 8;
static constexpr int CELL_VERTICAL_PADDING = 10;
static constexpr int CELL_ICON_SIZE = 16;
static constexpr int CELL_ICON_TEXT_SPACING = 6;
static constexpr int CELL_LABEL_VERTICAL_SPACING = 4;
static constexpr int SECTION_HEADER_HORIZONTAL_PADDING = 10;
static constexpr int SECTION_HEADER_VERTICAL_PADDING = 4;
static constexpr int MINIMUM_POPUP_WIDTH = 100;
static constexpr size_t MAXIMUM_VISIBLE_AUTOCOMPLETE_SUGGESTIONS = 8;
m_model->setStringList(list);
complete();
enum AutocompleteRole {
RowKindRole = Qt::UserRole + 1,
HeaderTextRole,
TitleRole,
UrlRole,
FaviconRole,
SourceRole,
SuggestionIndexRole,
};
enum class RowKind {
SectionHeader,
Suggestion,
};
struct RowModel {
RowKind kind;
String header_text;
size_t suggestion_index { 0 };
};
static QFont autocomplete_primary_font()
{
QFont font = QApplication::font();
font.setWeight(QFont::DemiBold);
return font;
}
static QFont autocomplete_secondary_font()
{
QFont font = QApplication::font();
if (font.pointSizeF() > 0)
font.setPointSizeF(font.pointSizeF() - 1.0);
return font;
}
static QFont autocomplete_section_header_font()
{
QFont font = autocomplete_secondary_font();
font.setWeight(QFont::DemiBold);
return font;
}
static QIcon globe_icon()
{
static QIcon icon = create_tvg_icon_with_theme_colors("globe", QApplication::palette());
return icon;
}
static QIcon search_icon()
{
static QIcon icon = create_tvg_icon_with_theme_colors("search", QApplication::palette());
return icon;
}
class AutocompleteModel final : public QAbstractListModel {
public:
explicit AutocompleteModel(QObject* parent)
: QAbstractListModel(parent)
{
}
void set_suggestions(Vector<WebView::AutocompleteSuggestion> suggestions)
{
beginResetModel();
m_suggestions = move(suggestions);
m_rows.clear();
m_favicon_cache.clear();
auto current_section = WebView::AutocompleteSuggestionSection::None;
for (size_t index = 0; index < m_suggestions.size(); ++index) {
auto const& suggestion = m_suggestions[index];
if (suggestion.section != WebView::AutocompleteSuggestionSection::None
&& suggestion.section != current_section) {
current_section = suggestion.section;
m_rows.append({
.kind = RowKind::SectionHeader,
.header_text = MUST(String::from_utf8(WebView::autocomplete_section_title(current_section))),
});
}
m_rows.append({ .kind = RowKind::Suggestion, .header_text = {}, .suggestion_index = index });
}
for (size_t index = 0; index < m_suggestions.size(); ++index) {
auto const& suggestion = m_suggestions[index];
if (!suggestion.favicon_base64_png.has_value())
continue;
auto decoded = decode_base64(*suggestion.favicon_base64_png);
if (decoded.is_error())
continue;
auto bytes = decoded.release_value();
QPixmap pixmap;
if (!pixmap.loadFromData(reinterpret_cast<uchar const*>(bytes.data()), static_cast<uint>(bytes.size())))
continue;
m_favicon_cache.append({ index, QIcon(pixmap) });
}
endResetModel();
}
int rowCount(QModelIndex const& parent = {}) const override
{
if (parent.isValid())
return 0;
return static_cast<int>(m_rows.size());
}
QVariant data(QModelIndex const& index, int role) const override
{
if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(m_rows.size()))
return {};
auto const& row = m_rows[index.row()];
if (role == RowKindRole)
return static_cast<int>(row.kind);
if (row.kind == RowKind::SectionHeader) {
if (role == HeaderTextRole || role == Qt::DisplayRole)
return qstring_from_ak_string(row.header_text);
return {};
}
auto const& suggestion = m_suggestions[row.suggestion_index];
switch (role) {
case Qt::DisplayRole:
case UrlRole:
return qstring_from_ak_string(suggestion.text);
case TitleRole:
if (suggestion.title.has_value())
return qstring_from_ak_string(*suggestion.title);
return {};
case FaviconRole:
for (auto const& entry : m_favicon_cache) {
if (entry.suggestion_index == row.suggestion_index)
return entry.icon;
}
return {};
case SourceRole:
return static_cast<int>(suggestion.source);
case SuggestionIndexRole:
return static_cast<int>(row.suggestion_index);
default:
return {};
}
}
Qt::ItemFlags flags(QModelIndex const& index) const override
{
if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(m_rows.size()))
return Qt::NoItemFlags;
auto const& row = m_rows[index.row()];
if (row.kind == RowKind::SectionHeader)
return Qt::ItemIsEnabled;
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
Vector<RowModel> const& rows() const { return m_rows; }
Vector<WebView::AutocompleteSuggestion> const& suggestions() const { return m_suggestions; }
int table_row_for_suggestion_index(int suggestion_index) const
{
if (suggestion_index < 0)
return -1;
for (size_t i = 0; i < m_rows.size(); ++i) {
if (m_rows[i].kind == RowKind::Suggestion
&& m_rows[i].suggestion_index == static_cast<size_t>(suggestion_index))
return static_cast<int>(i);
}
return -1;
}
size_t visible_suggestion_count() const
{
size_t count = 0;
for (auto const& row : m_rows) {
if (row.kind == RowKind::Suggestion)
++count;
}
return count;
}
private:
struct FaviconEntry {
size_t suggestion_index;
QIcon icon;
};
setCompletionMode(QCompleter::UnfilteredPopupCompletion);
setModel(m_model);
setPopup(m_popup);
}
Vector<WebView::AutocompleteSuggestion> m_suggestions;
Vector<RowModel> m_rows;
Vector<FaviconEntry> m_favicon_cache;
};
void Autocomplete::query_autocomplete_engine(String search_string)
class AutocompleteDelegate final : public QStyledItemDelegate {
public:
using QStyledItemDelegate::QStyledItemDelegate;
QSize sizeHint(QStyleOptionViewItem const&, QModelIndex const& index) const override
{
if (!index.isValid())
return {};
auto kind = static_cast<RowKind>(index.data(RowKindRole).toInt());
if (kind == RowKind::SectionHeader) {
QFontMetrics fm(autocomplete_section_header_font());
return QSize(0, fm.height() + SECTION_HEADER_VERTICAL_PADDING * 2);
}
QFontMetrics primary_fm(autocomplete_primary_font());
QFontMetrics secondary_fm(autocomplete_secondary_font());
int content_height = std::max(CELL_ICON_SIZE,
primary_fm.height() + CELL_LABEL_VERTICAL_SPACING + secondary_fm.height());
return QSize(0, content_height + CELL_VERTICAL_PADDING * 2);
}
void paint(QPainter* painter, QStyleOptionViewItem const& option, QModelIndex const& index) const override
{
painter->save();
auto kind = static_cast<RowKind>(index.data(RowKindRole).toInt());
if (kind == RowKind::SectionHeader) {
auto text = index.data(HeaderTextRole).toString();
painter->setFont(autocomplete_section_header_font());
painter->setPen(option.palette.color(QPalette::Disabled, QPalette::Text));
auto rect = option.rect.adjusted(
SECTION_HEADER_HORIZONTAL_PADDING, SECTION_HEADER_VERTICAL_PADDING,
-SECTION_HEADER_HORIZONTAL_PADDING, -SECTION_HEADER_VERTICAL_PADDING);
painter->drawText(rect, Qt::AlignLeft | Qt::AlignVCenter, text);
painter->restore();
return;
}
bool selected = option.state & QStyle::State_Selected;
if (selected) {
auto accent = option.palette.color(QPalette::Highlight);
accent.setAlpha(64);
auto rect = option.rect.adjusted(2, 3, -2, -3);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(Qt::NoPen);
painter->setBrush(accent);
painter->drawRoundedRect(rect, 6, 6);
}
auto favicon = index.data(FaviconRole).value<QIcon>();
auto source = static_cast<WebView::AutocompleteSuggestionSource>(index.data(SourceRole).toInt());
auto url_text = index.data(UrlRole).toString();
auto title_text = index.data(TitleRole).toString();
int icon_x = option.rect.left() + CELL_HORIZONTAL_PADDING;
int icon_y = option.rect.top() + (option.rect.height() - CELL_ICON_SIZE) / 2;
QRect icon_rect(icon_x, icon_y, CELL_ICON_SIZE, CELL_ICON_SIZE);
if (source == WebView::AutocompleteSuggestionSource::Search) {
search_icon().paint(painter, icon_rect);
} else if (source == WebView::AutocompleteSuggestionSource::History && !favicon.isNull()) {
favicon.paint(painter, icon_rect);
} else {
globe_icon().paint(painter, icon_rect);
}
int text_x = icon_x + CELL_ICON_SIZE + CELL_ICON_TEXT_SPACING;
int text_width = option.rect.right() - text_x - CELL_HORIZONTAL_PADDING;
if (text_width < 0)
text_width = 0;
QFontMetrics primary_fm(autocomplete_primary_font());
QFontMetrics secondary_fm(autocomplete_secondary_font());
if (!title_text.isEmpty()) {
int block_height = primary_fm.height() + CELL_LABEL_VERTICAL_SPACING + secondary_fm.height();
int block_y = option.rect.top() + (option.rect.height() - block_height) / 2;
painter->setFont(autocomplete_primary_font());
painter->setPen(option.palette.color(QPalette::Text));
auto elided_title = primary_fm.elidedText(title_text, Qt::ElideRight, text_width);
painter->drawText(QRect(text_x, block_y, text_width, primary_fm.height()),
Qt::AlignLeft | Qt::AlignVCenter, elided_title);
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);
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);
} else {
painter->setFont(QApplication::font());
painter->setPen(option.palette.color(QPalette::Text));
QFontMetrics fm(QApplication::font());
auto elided_url = fm.elidedText(url_text, Qt::ElideRight, text_width);
painter->drawText(
QRect(text_x, option.rect.top(), text_width, option.rect.height()),
Qt::AlignLeft | Qt::AlignVCenter, elided_url);
}
painter->restore();
}
};
Autocomplete::Autocomplete(QLineEdit* anchor)
: QObject(anchor)
, m_anchor(anchor)
, m_autocomplete(make<WebView::Autocomplete>())
{
m_autocomplete->query_autocomplete_engine(move(search_string));
// The popup is parented to the anchor's top-level window in
// position_popup() rather than made its own window, so that showing
// it never causes the address bar to lose keyboard focus.
m_popup = new QFrame();
m_popup->setFocusPolicy(Qt::NoFocus);
m_popup->setFrameShape(QFrame::StyledPanel);
m_popup->setFrameShadow(QFrame::Raised);
m_popup->setAutoFillBackground(true);
m_popup->hide();
m_list_view = new QListView(m_popup);
m_list_view->setFocusPolicy(Qt::NoFocus);
m_list_view->setSelectionMode(QAbstractItemView::SingleSelection);
m_list_view->setMouseTracking(true);
m_list_view->setFrameShape(QFrame::NoFrame);
m_list_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_list_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_model = new AutocompleteModel(this);
m_delegate = new AutocompleteDelegate(this);
m_list_view->setModel(m_model);
m_list_view->setItemDelegate(m_delegate);
auto* layout = new QVBoxLayout(m_popup);
layout->setContentsMargins(0, POPUP_PADDING, 0, POPUP_PADDING);
layout->setSpacing(0);
layout->addWidget(m_list_view);
connect(m_list_view, &QAbstractItemView::clicked, this, [this](QModelIndex const& index) {
if (!is_selectable_row(index.row()))
return;
emit suggestion_activated(index.data(UrlRole).toString());
});
connect(m_list_view, &QAbstractItemView::entered, this, [this](QModelIndex const& index) {
if (!is_selectable_row(index.row()))
return;
if (m_list_view->currentIndex() == index)
return;
select_row(index.row());
});
m_autocomplete->on_autocomplete_query_complete = [this](auto suggestions, auto result_kind) {
if (on_query_complete)
on_query_complete(move(suggestions), result_kind);
};
qApp->installEventFilter(this);
}
Autocomplete::~Autocomplete()
{
qApp->removeEventFilter(this);
delete m_popup;
}
void Autocomplete::query_autocomplete_engine(String query)
{
m_autocomplete->query_autocomplete_engine(move(query), MAXIMUM_VISIBLE_AUTOCOMPLETE_SUGGESTIONS);
}
void Autocomplete::cancel_pending_query()
{
m_autocomplete->cancel_pending_query();
}
void Autocomplete::show_with_suggestions(Vector<WebView::AutocompleteSuggestion> suggestions, int selected_suggestion_index)
{
m_model->set_suggestions(move(suggestions));
if (m_model->rowCount() == 0) {
close();
return;
}
position_popup();
if (!m_popup->isVisible())
m_popup->show();
int table_row = m_model->table_row_for_suggestion_index(selected_suggestion_index);
if (table_row == -1)
clear_selection();
else
select_row(table_row, false);
}
bool Autocomplete::close()
{
if (!m_popup->isVisible())
return false;
m_popup->hide();
emit did_close();
return true;
}
bool Autocomplete::is_visible() const
{
return m_popup && m_popup->isVisible();
}
void Autocomplete::clear_selection()
{
m_list_view->setCurrentIndex({});
}
Optional<String> Autocomplete::selected_suggestion() const
{
if (!is_visible())
return {};
auto index = m_list_view->currentIndex();
if (!index.isValid() || !is_selectable_row(index.row()))
return {};
auto suggestion_index = index.data(SuggestionIndexRole).toInt();
if (suggestion_index < 0 || suggestion_index >= static_cast<int>(m_model->suggestions().size()))
return {};
return m_model->suggestions()[suggestion_index].text;
}
bool Autocomplete::select_next_suggestion()
{
if (m_model->rowCount() == 0)
return false;
if (!m_popup->isVisible()) {
position_popup();
m_popup->show();
int row = step_to_selectable_row(-1, 1);
if (row != -1)
select_row(row);
return true;
}
auto current = m_list_view->currentIndex();
int start = current.isValid() ? current.row() : -1;
int row = step_to_selectable_row(start, 1);
if (row != -1)
select_row(row);
return true;
}
bool Autocomplete::select_previous_suggestion()
{
if (m_model->rowCount() == 0)
return false;
if (!m_popup->isVisible()) {
position_popup();
m_popup->show();
int row = step_to_selectable_row(0, -1);
if (row != -1)
select_row(row);
return true;
}
auto current = m_list_view->currentIndex();
int start = current.isValid() ? current.row() : 0;
int row = step_to_selectable_row(start, -1);
if (row != -1)
select_row(row);
return true;
}
bool Autocomplete::eventFilter(QObject* watched, QEvent* event)
{
if (event->type() == QEvent::MouseButtonPress && is_visible()) {
auto* mouse_event = static_cast<QMouseEvent*>(event);
auto global = mouse_event->globalPosition().toPoint();
auto popup_global = QRect(m_popup->mapToGlobal(QPoint(0, 0)), m_popup->size());
auto anchor_global = QRect(m_anchor->mapToGlobal(QPoint(0, 0)), m_anchor->size());
if (!popup_global.contains(global) && !anchor_global.contains(global))
close();
}
return QObject::eventFilter(watched, event);
}
void Autocomplete::position_popup()
{
int visible_count = static_cast<int>(std::min(m_model->visible_suggestion_count(), MAXIMUM_VISIBLE_AUTOCOMPLETE_SUGGESTIONS));
if (visible_count == 0)
return;
int total_height = 0;
int seen_suggestions = 0;
int row_count = m_model->rowCount();
for (int i = 0; i < row_count; ++i) {
auto index = m_model->index(i, 0);
QStyleOptionViewItem option;
option.initFrom(m_list_view);
int h = m_delegate->sizeHint(option, index).height();
total_height += h;
if (static_cast<RowKind>(index.data(RowKindRole).toInt()) == RowKind::Suggestion) {
++seen_suggestions;
if (seen_suggestions >= visible_count)
break;
}
}
auto* top_window = m_anchor->window();
if (!top_window)
return;
if (m_popup->parentWidget() != top_window)
m_popup->setParent(top_window);
int width = std::max(m_anchor->width(), MINIMUM_POPUP_WIDTH);
int frame_overhead = m_popup->frameWidth() * 2;
int popup_height = total_height + POPUP_PADDING * 2 + frame_overhead;
m_list_view->setFixedHeight(total_height);
m_popup->setFixedSize(width, popup_height);
auto pos_in_window = m_anchor->mapTo(top_window, QPoint(0, m_anchor->height()));
m_popup->move(pos_in_window);
m_popup->raise();
}
bool Autocomplete::is_selectable_row(int row) const
{
if (row < 0 || row >= m_model->rowCount())
return false;
auto const& rows = m_model->rows();
return rows[row].kind == RowKind::Suggestion;
}
int Autocomplete::step_to_selectable_row(int from, int direction) const
{
int n = m_model->rowCount();
if (n == 0)
return -1;
int candidate = from;
for (int attempt = 0; attempt < n; ++attempt) {
candidate += direction;
if (candidate < 0)
candidate = n - 1;
else if (candidate >= n)
candidate = 0;
if (is_selectable_row(candidate))
return candidate;
}
return -1;
}
void Autocomplete::select_row(int row, bool notify)
{
if (!is_selectable_row(row))
return;
auto index = m_model->index(row, 0);
m_list_view->setCurrentIndex(index);
m_list_view->scrollTo(index);
if (notify)
emit suggestion_highlighted(index.data(UrlRole).toString());
}
}

View file

@ -1,35 +1,73 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Function.h>
#include <AK/NonnullOwnPtr.h>
#include <AK/Optional.h>
#include <AK/String.h>
#include <LibWebView/Forward.h>
#include <AK/Vector.h>
#include <LibWebView/Autocomplete.h>
#include <QCompleter>
#include <QListView>
#include <QStringListModel>
#include <QObject>
class QFrame;
class QLineEdit;
class QListView;
namespace Ladybird {
class Autocomplete final : public QCompleter {
class AutocompleteModel;
class AutocompleteDelegate;
class Autocomplete final : public QObject {
Q_OBJECT
public:
explicit Autocomplete(QWidget* parent);
explicit Autocomplete(QLineEdit* anchor);
virtual ~Autocomplete() override;
AK::Function<void(Vector<WebView::AutocompleteSuggestion>, WebView::AutocompleteResultKind)> on_query_complete;
void query_autocomplete_engine(String);
void cancel_pending_query();
void show_with_suggestions(Vector<WebView::AutocompleteSuggestion>, int selected_suggestion_index);
bool close();
bool is_visible() const;
void clear_selection();
Optional<String> selected_suggestion() const;
bool select_next_suggestion();
bool select_previous_suggestion();
signals:
void suggestion_activated(QString);
void suggestion_highlighted(QString);
void did_close();
protected:
virtual bool eventFilter(QObject* watched, QEvent* event) override;
private:
NonnullOwnPtr<WebView::Autocomplete> m_autocomplete;
void position_popup();
bool is_selectable_row(int row) const;
int step_to_selectable_row(int from, int direction) const;
void select_row(int row, bool notify = true);
QStringListModel* m_model { nullptr };
QListView* m_popup { nullptr };
QLineEdit* m_anchor { nullptr };
QFrame* m_popup { nullptr };
QListView* m_list_view { nullptr };
AutocompleteModel* m_model { nullptr };
AutocompleteDelegate* m_delegate { nullptr };
NonnullOwnPtr<WebView::Autocomplete> m_autocomplete;
};
}

View file

@ -1,11 +1,14 @@
/*
* 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>
@ -13,28 +16,163 @@
#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();
setCompleter(m_autocomplete);
m_autocomplete->on_query_complete = [this](auto suggestions, WebView::AutocompleteResultKind result_kind) {
int selected_row = apply_inline_autocomplete(suggestions);
connect(m_autocomplete, QOverload<QModelIndex const&>::of(&QCompleter::activated), [&](QModelIndex const&) {
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(this, &QLineEdit::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());
@ -46,8 +184,27 @@ LocationEdit::LocationEdit(QWidget* parent)
set_url(url.release_value());
});
connect(this, &QLineEdit::textEdited, [this] {
m_autocomplete->query_autocomplete_engine(ak_string_from_qstring(text()));
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);
@ -66,6 +223,10 @@ 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())
@ -81,14 +242,36 @@ void LocationEdit::focusOutEvent(QFocusEvent* event)
void LocationEdit::keyPressEvent(QKeyEvent* event)
{
if (event->key() == Qt::Key_Escape) {
if (m_autocomplete->popup()->isVisible()) {
QLineEdit::keyPressEvent(event);
if (m_autocomplete->close())
return;
}
reset_autocomplete_state();
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);
}
@ -160,4 +343,184 @@ void LocationEdit::set_url(URL::URL url)
}
}
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;
}
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -7,9 +8,12 @@
#pragma once
#include <AK/OwnPtr.h>
#include <AK/Vector.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/Settings.h>
#include <QLineEdit>
#include <QString>
namespace Ladybird {
@ -39,10 +43,22 @@ private:
void update_placeholder();
void highlight_location();
int apply_inline_autocomplete(Vector<WebView::AutocompleteSuggestion> const&);
bool apply_inline_autocomplete_suggestion_text(QString const& suggestion_text, QString const& query);
void apply_inline_autocomplete_text(QString const& inline_text, QString const& query);
void restore_query();
QString current_query() const;
void reset_autocomplete_state();
Autocomplete* m_autocomplete { nullptr };
URL::URL m_url;
bool m_url_is_hidden { false };
bool m_is_applying_inline_autocomplete { false };
bool m_should_suppress_inline_autocomplete_on_next_change { false };
QString m_current_inline_autocomplete_suggestion;
QString m_suppressed_inline_autocomplete_query;
};
}

View file

@ -9,6 +9,7 @@
<file>../Icons/hamburger.tvg</file>
<file>../Icons/new_tab.tvg</file>
<file>../Icons/reload.tvg</file>
<file>../Icons/search.tvg</file>
<file>../Icons/star-countour.tvg</file>
<file>../Icons/star-filled.tvg</file>
<file>../Icons/up.tvg</file>