From d68433653a69655a628698ed5fc2472cea32800f Mon Sep 17 00:00:00 2001 From: Cameron Youell Date: Wed, 31 May 2023 11:44:16 +1000 Subject: [PATCH] Ladybird: Add autocomplete to `LocationEdit` --- Ladybird/CMakeLists.txt | 3 +- Ladybird/Qt/AutoComplete.cpp | 156 +++++++++++++++++++++++++++++++++ Ladybird/Qt/AutoComplete.h | 85 ++++++++++++++++++ Ladybird/Qt/LocationEdit.cpp | 50 ++++++++++- Ladybird/Qt/LocationEdit.h | 3 + Ladybird/Qt/Settings.cpp | 49 +++++++++++ Ladybird/Qt/Settings.h | 18 ++++ Ladybird/Qt/SettingsDialog.cpp | 85 +++++++++++++++++- Ladybird/Qt/SettingsDialog.h | 12 ++- 9 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 Ladybird/Qt/AutoComplete.cpp create mode 100644 Ladybird/Qt/AutoComplete.h diff --git a/Ladybird/CMakeLists.txt b/Ladybird/CMakeLists.txt index a5b19b0d2d6..318bf6bd25e 100644 --- a/Ladybird/CMakeLists.txt +++ b/Ladybird/CMakeLists.txt @@ -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/) diff --git a/Ladybird/Qt/AutoComplete.cpp b/Ladybird/Qt/AutoComplete.cpp new file mode 100644 index 00000000000..93d5f645f61 --- /dev/null +++ b/Ladybird/Qt/AutoComplete.cpp @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2023, Cameron Youell + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "AutoComplete.h" +#include "Settings.h" +#include +#include +#include +#include + +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::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 AutoComplete::parse_google_autocomplete(Vector 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 AutoComplete::parse_duckduckgo_autocomplete(Vector 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 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 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 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 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 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 {}; +} + +} diff --git a/Ladybird/Qt/AutoComplete.h b/Ladybird/Qt/AutoComplete.h new file mode 100644 index 00000000000..7772f1eb086 --- /dev/null +++ b/Ladybird/Qt/AutoComplete.h @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023, Cameron Youell + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "StringUtils.h" +#include +#include +#include +#include +#include + +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 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 get_search_suggestions(StringView); + void clear_suggestions(); + static ErrorOr search_url_from_query(StringView query); + static ErrorOr auto_complete_url_from_query(StringView query); + +signals: + void activated(QModelIndex const&); + +private: + ErrorOr got_network_response(QNetworkReply* reply); + + ErrorOr parse_google_autocomplete(Vector const&); + ErrorOr parse_duckduckgo_autocomplete(Vector const&); + ErrorOr parse_yahoo_autocomplete(JsonObject const&); + + QNetworkAccessManager* m_manager; + AutoCompleteModel* m_auto_complete_model; + QTreeView* m_tree_view; + QNetworkReply* m_reply { nullptr }; + + String m_query; +}; + +} diff --git a/Ladybird/Qt/LocationEdit.cpp b/Ladybird/Qt/LocationEdit.cpp index 7bbb64b9cd6..788f290a90d 100644 --- a/Ladybird/Qt/LocationEdit.cpp +++ b/Ladybird/Qt/LocationEdit.cpp @@ -5,9 +5,11 @@ */ #include "LocationEdit.h" +#include "Settings.h" #include "StringUtils.h" #include -#include +#include +#include #include #include #include @@ -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(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); } diff --git a/Ladybird/Qt/LocationEdit.h b/Ladybird/Qt/LocationEdit.h index 9cec05d144f..668792a4f20 100644 --- a/Ladybird/Qt/LocationEdit.h +++ b/Ladybird/Qt/LocationEdit.h @@ -6,6 +6,8 @@ #pragma once +#include "AutoComplete.h" +#include #include namespace Ladybird { @@ -20,6 +22,7 @@ private: virtual void focusOutEvent(QFocusEvent* event) override; void highlight_location(); + AK::OwnPtr m_autocomplete; }; } diff --git a/Ladybird/Qt/Settings.cpp b/Ladybird/Qt/Settings.cpp index ec6844f2246..417adc8b132 100644 --- a/Ladybird/Qt/Settings.cpp +++ b/Ladybird/Qt/Settings.cpp @@ -1,5 +1,6 @@ /* * Copyright (c) 2022, Filiph Sandström + * Copyright (c) 2023, Cameron Youell * * 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); +} + } diff --git a/Ladybird/Qt/Settings.h b/Ladybird/Qt/Settings.h index e26e3e1f8e9..21c0779c5c2 100644 --- a/Ladybird/Qt/Settings.h +++ b/Ladybird/Qt/Settings.h @@ -1,5 +1,6 @@ /* * Copyright (c) 2022, Filiph Sandström + * Copyright (c) 2023, Cameron Youell * * 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(); diff --git a/Ladybird/Qt/SettingsDialog.cpp b/Ladybird/Qt/SettingsDialog.cpp index 9c166c6f2d6..be69a92e459 100644 --- a/Ladybird/Qt/SettingsDialog.cpp +++ b/Ladybird/Qt/SettingsDialog.cpp @@ -1,5 +1,6 @@ /* * Copyright (c) 2022, Filiph Sandström + * Copyright (c) 2023, Cameron Youell * * SPDX-License-Identifier: BSD-2-Clause */ @@ -10,6 +11,7 @@ #include #include #include +#include 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(this); + m_enable_search->setChecked(Settings::the()->enable_search()); + + m_search_engine_dropdown = make(this); + m_search_engine_dropdown->setText(Settings::the()->search_engine().name); + + m_enable_autocomplete = make(this); + m_enable_autocomplete->setChecked(Settings::the()->enable_autocomplete()); + + m_autocomplete_engine_dropdown = make(this); + m_autocomplete_engine_dropdown->setText(Settings::the()->autocomplete_engine().name); + + m_new_tab_page = make(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 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 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(); diff --git a/Ladybird/Qt/SettingsDialog.h b/Ladybird/Qt/SettingsDialog.h index 2a8402f8648..80eaa5df760 100644 --- a/Ladybird/Qt/SettingsDialog.h +++ b/Ladybird/Qt/SettingsDialog.h @@ -1,14 +1,18 @@ /* * Copyright (c) 2022, Filiph Sandström + * Copyright (c) 2023, Cameron Youell * * SPDX-License-Identifier: BSD-2-Clause */ +#include +#include #include #include #include #include #include +#include #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 m_new_tab_page; + OwnPtr m_enable_search; + OwnPtr m_search_engine_dropdown; + OwnPtr m_enable_autocomplete; + OwnPtr m_autocomplete_engine_dropdown; }; }