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:
parent
ed34a4ba8a
commit
bb9f789eae
6 changed files with 1012 additions and 38 deletions
BIN
UI/Icons/search.tvg
Normal file
BIN
UI/Icons/search.tvg
Normal file
Binary file not shown.
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue