Ladybird: Add autocomplete to LocationEdit

This commit is contained in:
Cameron Youell 2023-05-31 11:44:16 +10:00 committed by Sam Atkins
parent 8fcf42f684
commit d68433653a
Notes: sideshowbarker 2024-07-16 23:52:22 +09:00
9 changed files with 453 additions and 8 deletions

View file

@ -106,6 +106,7 @@ set(LADYBIRD_HEADERS
if (ENABLE_QT)
qt_add_executable(ladybird ${SOURCES})
target_sources(ladybird PRIVATE
Qt/AutoComplete.cpp
Qt/BrowserWindow.cpp
Qt/ConsoleWidget.cpp
Qt/EventLoopImplementationQt.cpp
@ -136,7 +137,7 @@ target_sources(ladybird PUBLIC FILE_SET ladybird TYPE HEADERS
BASE_DIRS ${SERENITY_SOURCE_DIR}
FILES ${LADYBIRD_HEADERS}
)
target_link_libraries(ladybird PRIVATE LibCore LibFileSystem LibGfx LibGUI LibIPC LibJS LibMain LibWeb LibWebView LibSQL LibProtocol)
target_link_libraries(ladybird PRIVATE LibCore LibFileSystem LibGfx LibGUI LibIPC LibJS LibMain LibPublicSuffix LibWeb LibWebView LibSQL LibProtocol)
target_include_directories(ladybird PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
target_include_directories(ladybird PRIVATE ${SERENITY_SOURCE_DIR}/Userland/)

View file

@ -0,0 +1,156 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "AutoComplete.h"
#include "Settings.h"
#include <AK/JsonArray.h>
#include <AK/JsonObject.h>
#include <AK/JsonParser.h>
#include <AK/URL.h>
namespace Ladybird {
AutoComplete::AutoComplete(QWidget* parent)
: QCompleter(parent)
{
m_tree_view = new QTreeView(parent);
m_manager = new QNetworkAccessManager(this);
m_auto_complete_model = new AutoCompleteModel(this);
setCompletionMode(QCompleter::UnfilteredPopupCompletion);
setModel(m_auto_complete_model);
setPopup(m_tree_view);
m_tree_view->setRootIsDecorated(false);
m_tree_view->setHeaderHidden(true);
connect(this, QOverload<QModelIndex const&>::of(&QCompleter::activated), this, [&](QModelIndex const& index) {
emit activated(index);
});
connect(m_manager, &QNetworkAccessManager::finished, this, [&](QNetworkReply* reply) {
auto result = got_network_response(reply);
if (result.is_error())
dbgln("AutoComplete::got_network_response: Error {}", result.error());
});
}
ErrorOr<void> AutoComplete::parse_google_autocomplete(Vector<JsonValue> const& json)
{
if (json.size() != 5)
return Error::from_string_view("Invalid JSON, expected 5 elements in array"sv);
if (!json[0].is_string())
return Error::from_string_view("Invalid JSON, expected first element to be a string"sv);
auto query = TRY(String::from_deprecated_string(json[0].as_string()));
if (!json[1].is_array())
return Error::from_string_view("Invalid JSON, expected second element to be an array"sv);
auto suggestions_array = json[1].as_array().values();
if (query != m_query)
return Error::from_string_view("Invalid JSON, query does not match"sv);
for (auto& suggestion : suggestions_array) {
m_auto_complete_model->add(TRY(String::from_deprecated_string(suggestion.as_string())));
}
return {};
}
ErrorOr<void> AutoComplete::parse_duckduckgo_autocomplete(Vector<JsonValue> const& json)
{
for (auto const& suggestion : json) {
auto maybe_value = suggestion.as_object().get("phrase"sv);
if (!maybe_value.has_value())
continue;
m_auto_complete_model->add(TRY(String::from_deprecated_string(maybe_value->as_string())));
}
return {};
}
ErrorOr<void> AutoComplete::parse_yahoo_autocomplete(JsonObject const& json)
{
if (!json.get("q"sv).has_value() || !json.get("q"sv)->is_string())
return Error::from_string_view("Invalid JSON, expected \"q\" to be a string"sv);
auto query = TRY(String::from_deprecated_string(json.get("q"sv)->as_string()));
if (!json.get("r"sv).has_value() || !json.get("r"sv)->is_array())
return Error::from_string_view("Invalid JSON, expected \"r\" to be an object"sv);
auto suggestions_object = json.get("r"sv)->as_array().values();
if (query != m_query)
return Error::from_string_view("Invalid JSON, query does not match"sv);
for (auto& suggestion_object : suggestions_object) {
if (!suggestion_object.is_object())
return Error::from_string_view("Invalid JSON, expected value to be an object"sv);
auto suggestion = suggestion_object.as_object();
if (!suggestion.get("k"sv).has_value() || !suggestion.get("k"sv)->is_string())
return Error::from_string_view("Invalid JSON, expected \"k\" to be a string"sv);
m_auto_complete_model->add(TRY(String::from_deprecated_string(suggestion.get("k"sv)->as_string())));
};
return {};
}
ErrorOr<void> AutoComplete::got_network_response(QNetworkReply* reply)
{
if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError)
return {};
AK::JsonParser parser(ak_deprecated_string_from_qstring(reply->readAll()));
auto json = TRY(parser.parse());
auto engine_name = Settings::the()->autocomplete_engine().name;
if (engine_name == "Google")
return parse_google_autocomplete(json.as_array().values());
if (engine_name == "DuckDuckGo")
return parse_duckduckgo_autocomplete(json.as_array().values());
if (engine_name == "Yahoo")
return parse_yahoo_autocomplete(json.as_object());
return Error::from_string_view("Invalid engine name"sv);
}
ErrorOr<String> AutoComplete::search_url_from_query(StringView query)
{
auto search_engine = TRY(ak_string_from_qstring(Settings::the()->search_engine().url));
return search_engine.replace("{}"sv, AK::URL::percent_encode(query), ReplaceMode::FirstOnly);
}
ErrorOr<String> AutoComplete::auto_complete_url_from_query(StringView query)
{
auto autocomplete_engine = TRY(ak_string_from_qstring(Settings::the()->autocomplete_engine().url));
return autocomplete_engine.replace("{}"sv, AK::URL::percent_encode(query), ReplaceMode::FirstOnly);
}
void AutoComplete::clear_suggestions()
{
m_auto_complete_model->clear();
}
ErrorOr<void> AutoComplete::get_search_suggestions(StringView search_string)
{
m_query = TRY(String::from_utf8(search_string));
if (m_reply)
m_reply->abort();
m_auto_complete_model->clear();
m_auto_complete_model->add(m_query);
QNetworkRequest request { QUrl(qstring_from_ak_string(TRY(auto_complete_url_from_query(m_query)))) };
m_reply = m_manager->get(request);
return {};
}
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include "StringUtils.h"
#include <AK/Forward.h>
#include <AK/String.h>
#include <QCompleter>
#include <QNetworkReply>
#include <QTreeView>
namespace Ladybird {
class AutoCompleteModel final : public QAbstractListModel {
Q_OBJECT
public:
explicit AutoCompleteModel(QObject* parent)
: QAbstractListModel(parent)
{
}
virtual int rowCount(QModelIndex const& parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_suggestions.size(); }
virtual QVariant data(QModelIndex const& index, int role = Qt::DisplayRole) const override
{
if (role == Qt::DisplayRole || role == Qt::EditRole)
return qstring_from_ak_string(m_suggestions[index.row()]);
return {};
}
void add(String const& result)
{
beginInsertRows({}, m_suggestions.size(), m_suggestions.size());
m_suggestions.append(result);
endInsertRows();
}
void clear()
{
beginResetModel();
m_suggestions.clear();
endResetModel();
}
private:
AK::Vector<String> m_suggestions;
};
class AutoComplete final : public QCompleter {
Q_OBJECT
public:
AutoComplete(QWidget* parent);
virtual QString pathFromIndex(QModelIndex const& index) const override
{
return index.data(Qt::DisplayRole).toString();
}
ErrorOr<void> get_search_suggestions(StringView);
void clear_suggestions();
static ErrorOr<String> search_url_from_query(StringView query);
static ErrorOr<String> auto_complete_url_from_query(StringView query);
signals:
void activated(QModelIndex const&);
private:
ErrorOr<void> got_network_response(QNetworkReply* reply);
ErrorOr<void> parse_google_autocomplete(Vector<JsonValue> const&);
ErrorOr<void> parse_duckduckgo_autocomplete(Vector<JsonValue> const&);
ErrorOr<void> parse_yahoo_autocomplete(JsonObject const&);
QNetworkAccessManager* m_manager;
AutoCompleteModel* m_auto_complete_model;
QTreeView* m_tree_view;
QNetworkReply* m_reply { nullptr };
String m_query;
};
}

View file

@ -5,9 +5,11 @@
*/
#include "LocationEdit.h"
#include "Settings.h"
#include "StringUtils.h"
#include <AK/URL.h>
#include <QCoreApplication>
#include <LibPublicSuffix/URL.h>
#include <QApplication>
#include <QPalette>
#include <QTextLayout>
#include <QTimer>
@ -17,8 +19,50 @@ namespace Ladybird {
LocationEdit::LocationEdit(QWidget* parent)
: QLineEdit(parent)
{
setPlaceholderText("Enter web address");
connect(this, &QLineEdit::editingFinished, this, &LocationEdit::clearFocus);
setPlaceholderText("Search or enter web address");
m_autocomplete = make<AutoComplete>(this);
this->setCompleter(m_autocomplete);
connect(m_autocomplete, &AutoComplete::activated, [&](QModelIndex const&) {
emit returnPressed();
});
connect(this, &QLineEdit::returnPressed, [&] {
clearFocus();
if (!Settings::the()->enable_search())
return;
auto query = ak_deprecated_string_from_qstring(text());
if (auto result = PublicSuffix::absolute_url(query); !result.is_error())
return;
auto search_url_or_error = AutoComplete::search_url_from_query(query);
if (search_url_or_error.is_error()) {
dbgln("LocationEdit::returnPressed: search_url_from_query failed: {}", search_url_or_error.error());
return;
}
auto search_url = search_url_or_error.release_value();
setText(qstring_from_ak_string(search_url));
});
connect(this, &QLineEdit::textEdited, [this] {
if (!Settings::the()->enable_autocomplete()) {
m_autocomplete->clear_suggestions();
return;
}
auto cursor_position = cursorPosition();
auto result = m_autocomplete->get_search_suggestions(ak_deprecated_string_from_qstring(text()));
if (result.is_error()) {
dbgln("LocationEdit::textEdited: get_search_suggestions failed: {}", result.error());
return;
}
setCursorPosition(cursor_position);
});
connect(this, &QLineEdit::textChanged, this, &LocationEdit::highlight_location);
}

View file

@ -6,6 +6,8 @@
#pragma once
#include "AutoComplete.h"
#include <AK/OwnPtr.h>
#include <QLineEdit>
namespace Ladybird {
@ -20,6 +22,7 @@ private:
virtual void focusOutEvent(QFocusEvent* event) override;
void highlight_location();
AK::OwnPtr<AutoComplete> m_autocomplete;
};
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022, Filiph Sandström <filiph.sandstrom@filfatstudios.com>
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -71,9 +72,57 @@ QString Settings::new_tab_page()
return m_qsettings->value("new_tab_page", default_new_tab_url).toString();
}
Settings::EngineProvider Settings::search_engine()
{
EngineProvider engine_provider;
engine_provider.name = m_qsettings->value("search_engine_name", "Google").toString();
engine_provider.url = m_qsettings->value("search_engine", "https://www.google.com/search?q={}").toString();
return engine_provider;
}
void Settings::set_search_engine(EngineProvider const& engine_provider)
{
m_qsettings->setValue("search_engine_name", engine_provider.name);
m_qsettings->setValue("search_engine", engine_provider.url);
}
Settings::EngineProvider Settings::autocomplete_engine()
{
EngineProvider engine_provider;
engine_provider.name = m_qsettings->value("autocomplete_engine_name", "Google").toString();
engine_provider.url = m_qsettings->value("autocomplete_engine", "https://www.google.com/complete/search?client=chrome&q={}").toString();
return engine_provider;
}
void Settings::set_autocomplete_engine(EngineProvider const& engine_provider)
{
m_qsettings->setValue("autocomplete_engine_name", engine_provider.name);
m_qsettings->setValue("autocomplete_engine", engine_provider.url);
}
void Settings::set_new_tab_page(QString const& page)
{
m_qsettings->setValue("new_tab_page", page);
}
bool Settings::enable_autocomplete()
{
return m_qsettings->value("enable_autocomplete", true).toBool();
}
void Settings::set_enable_autocomplete(bool enable)
{
m_qsettings->setValue("enable_autocomplete", enable);
}
bool Settings::enable_search()
{
return m_qsettings->value("enable_search", true).toBool();
}
void Settings::set_enable_search(bool enable)
{
m_qsettings->setValue("enable_search", enable);
}
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022, Filiph Sandström <filiph.sandstrom@filfatstudios.com>
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -37,6 +38,23 @@ public:
QString new_tab_page();
void set_new_tab_page(QString const& page);
struct EngineProvider {
QString name;
QString url;
};
EngineProvider search_engine();
void set_search_engine(EngineProvider const& engine);
EngineProvider autocomplete_engine();
void set_autocomplete_engine(EngineProvider const& engine);
bool enable_autocomplete();
void set_enable_autocomplete(bool enable);
bool enable_search();
void set_enable_search(bool enable);
protected:
Settings();

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022, Filiph Sandström <filiph.sandstrom@filfatstudios.com>
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -10,6 +11,7 @@
#include <AK/URL.h>
#include <QCloseEvent>
#include <QLabel>
#include <QMenu>
namespace Ladybird {
@ -17,11 +19,31 @@ SettingsDialog::SettingsDialog(QMainWindow* window)
: m_window(window)
{
m_layout = new QFormLayout(this);
m_new_tab_page = new QLineEdit(this);
m_ok_button = new QPushButton("&Save", this);
m_enable_search = make<QCheckBox>(this);
m_enable_search->setChecked(Settings::the()->enable_search());
m_search_engine_dropdown = make<QToolButton>(this);
m_search_engine_dropdown->setText(Settings::the()->search_engine().name);
m_enable_autocomplete = make<QCheckBox>(this);
m_enable_autocomplete->setChecked(Settings::the()->enable_autocomplete());
m_autocomplete_engine_dropdown = make<QToolButton>(this);
m_autocomplete_engine_dropdown->setText(Settings::the()->autocomplete_engine().name);
m_new_tab_page = make<QLineEdit>(this);
m_new_tab_page->setText(Settings::the()->new_tab_page());
setup_search_engines();
m_layout->addRow(new QLabel("Page on New Tab", this), m_new_tab_page);
m_layout->addWidget(m_ok_button);
m_layout->addRow(new QLabel("Enable Search", this), m_enable_search);
m_layout->addRow(new QLabel("Search Engine", this), m_search_engine_dropdown);
m_layout->addRow(new QLabel("Enable Autocomplete", this), m_enable_autocomplete);
m_layout->addRow(new QLabel("Autocomplete Engine", this), m_autocomplete_engine_dropdown);
m_layout->addRow(m_ok_button);
QObject::connect(m_ok_button, &QPushButton::released, this, [this] {
close();
@ -29,12 +51,69 @@ SettingsDialog::SettingsDialog(QMainWindow* window)
setWindowTitle("Settings");
setFixedWidth(300);
setFixedHeight(150);
setFixedHeight(170);
setLayout(m_layout);
show();
setFocus();
}
void SettingsDialog::setup_search_engines()
{
// FIXME: These should be in a config file.
Vector<Settings::EngineProvider> search_engines = {
{ "Bing", "https://www.bing.com/search?q={}" },
{ "Brave", "https://search.brave.com/search?q={}" },
{ "DuckDuckGo", "https://duckduckgo.com/?q={}" },
{ "GitHub", "https://github.com/search?q={}" },
{ "Google", "https://google.com/search?q={}" },
{ "Mojeek", "https://www.mojeek.com/search?q={}" },
{ "Yahoo", "https://search.yahoo.com/search?p={}" },
{ "Yandex", "https://yandex.com/search/?text={}" },
};
Vector<Settings::EngineProvider> autocomplete_engines = {
{ "DuckDuckGo", "https://duckduckgo.com/ac/?q={}" },
{ "Google", "https://www.google.com/complete/search?client=chrome&q={}" },
{ "Yahoo", "https://search.yahoo.com/sugg/gossip/gossip-us-ura/?output=sd1&command={}" },
};
QMenu* search_engine_menu = new QMenu(this);
for (auto& search_engine : search_engines) {
QAction* action = new QAction(search_engine.name, this);
connect(action, &QAction::triggered, this, [&, search_engine] {
Settings::the()->set_search_engine(search_engine);
m_search_engine_dropdown->setText(search_engine.name);
});
search_engine_menu->addAction(action);
}
m_search_engine_dropdown->setMenu(search_engine_menu);
m_search_engine_dropdown->setPopupMode(QToolButton::InstantPopup);
m_search_engine_dropdown->setEnabled(Settings::the()->enable_search());
QMenu* autocomplete_engine_menu = new QMenu(this);
for (auto& autocomplete_engine : autocomplete_engines) {
QAction* action = new QAction(autocomplete_engine.name, this);
connect(action, &QAction::triggered, this, [&, autocomplete_engine] {
Settings::the()->set_autocomplete_engine(autocomplete_engine);
m_autocomplete_engine_dropdown->setText(autocomplete_engine.name);
});
autocomplete_engine_menu->addAction(action);
}
m_autocomplete_engine_dropdown->setMenu(autocomplete_engine_menu);
m_autocomplete_engine_dropdown->setPopupMode(QToolButton::InstantPopup);
m_autocomplete_engine_dropdown->setEnabled(Settings::the()->enable_autocomplete());
connect(m_enable_search, &QCheckBox::stateChanged, this, [&](int state) {
Settings::the()->set_enable_search(state == Qt::Checked);
m_search_engine_dropdown->setEnabled(state == Qt::Checked);
});
connect(m_enable_autocomplete, &QCheckBox::stateChanged, this, [&](int state) {
Settings::the()->set_enable_autocomplete(state == Qt::Checked);
m_autocomplete_engine_dropdown->setEnabled(state == Qt::Checked);
});
}
void SettingsDialog::closeEvent(QCloseEvent* event)
{
save();

View file

@ -1,14 +1,18 @@
/*
* Copyright (c) 2022, Filiph Sandström <filiph.sandstrom@filfatstudios.com>
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/OwnPtr.h>
#include <QCheckBox>
#include <QDialog>
#include <QFormLayout>
#include <QLineEdit>
#include <QMainWindow>
#include <QPushButton>
#include <QToolButton>
#pragma once
@ -24,10 +28,16 @@ public:
virtual void closeEvent(QCloseEvent*) override;
private:
void setup_search_engines();
QFormLayout* m_layout;
QPushButton* m_ok_button { nullptr };
QLineEdit* m_new_tab_page { nullptr };
QMainWindow* m_window { nullptr };
OwnPtr<QLineEdit> m_new_tab_page;
OwnPtr<QCheckBox> m_enable_search;
OwnPtr<QToolButton> m_search_engine_dropdown;
OwnPtr<QCheckBox> m_enable_autocomplete;
OwnPtr<QToolButton> m_autocomplete_engine_dropdown;
};
}