kristall/src/browsertab.cpp

1855 lines
60 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "browsertab.hpp"
#include "ui_browsertab.h"
#include "mainwindow.hpp"
#include "renderers/gophermaprenderer.hpp"
#include "renderers/geminirenderer.hpp"
#include "renderers/plaintextrenderer.hpp"
#include "renderers/markdownrenderer.hpp"
#include "renderers/htmlrenderer.hpp"
#include "renderers/renderhelpers.hpp"
#include "mimeparser.hpp"
#include "dialogs/settingsdialog.hpp"
#include "dialogs/certificateselectiondialog.hpp"
#include "protocols/geminiclient.hpp"
#include "protocols/webclient.hpp"
#include "protocols/gopherclient.hpp"
#include "protocols/guppyclient.hpp"
#include "protocols/fingerclient.hpp"
#include "protocols/abouthandler.hpp"
#include "protocols/filehandler.hpp"
#include "ioutil.hpp"
#include "kristall.hpp"
#include "widgets/favouritepopup.hpp"
#include "widgets/searchbox.hpp"
#include "widgets/querydialog.hpp"
#include <cassert>
#include <QTabWidget>
#include <QtGlobal>
#include <QMenu>
#include <QMessageBox>
#include <QInputDialog>
#include <QDockWidget>
#include <QImage>
#include <QPixmap>
#include <QFile>
#include <QMimeDatabase>
#include <QMimeType>
#include <QImageReader>
#include <QClipboard>
#include <QDesktopServices>
#include <QShortcut>
#include <QKeySequence>
#include <QDir>
#include <QScrollBar>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QGraphicsPixmapItem>
#include <QGraphicsTextItem>
#include <QRegularExpression>
#include <iconv.h>
BrowserTab::BrowserTab(MainWindow *mainWindow) : QWidget(nullptr),
ui(new Ui::BrowserTab),
mainWindow(mainWindow),
current_handler(nullptr),
outline(),
graphics_scene()
{
ui->setupUi(this);
connect( // connect with "this" as context, so the connection will die when the window is destroyed
kristall::globals().localization.get(), &Localization::translationChanged,
this, [this]() { this->ui->retranslateUi(this); },
Qt::DirectConnection
);
this->setUiDensity(kristall::globals().options.ui_density);
addProtocolHandler<GeminiClient>();
addProtocolHandler<FingerClient>();
addProtocolHandler<GopherClient>();
addProtocolHandler<GuppyClient>();
addProtocolHandler<WebClient>();
addProtocolHandler<AboutHandler>();
addProtocolHandler<FileHandler>();
this->updateUI();
this->ui->search_bar->setVisible(false);
this->ui->media_browser->setVisible(false);
this->ui->graphics_browser->setVisible(false);
this->ui->text_browser->setVisible(true);
#if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0)
this->ui->text_browser->setTabStopDistance(40);
#else
this->ui->text_browser->setTabStopWidth(40);
#endif
this->ui->text_browser->setContextMenuPolicy(Qt::CustomContextMenu);
this->ui->text_browser->verticalScrollBar()->setTracking(true);
// We hide horizontal scroll bars for now, however mouse-scrolling (overshooting?)
// causes the page to still scroll horizontally. TODO: Fix this
this->ui->text_browser->horizontalScrollBar()->setEnabled(false);
this->ui->text_browser->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
connect(this->ui->url_bar, &SearchBar::escapePressed, this, &BrowserTab::on_url_bar_escapePressed);
this->network_timeout_timer.setSingleShot(true);
this->network_timeout_timer.setTimerType(Qt::PreciseTimer);
connect(&this->network_timeout_timer, &QTimer::timeout, this, &BrowserTab::on_networkTimeout);
{
QShortcut * sc = new QShortcut(QKeySequence("Ctrl+F"), this);
connect(sc, &QShortcut::activated, this, &BrowserTab::on_focusSearchbar);
}
{
QShortcut * sc = new QShortcut(QKeySequence("Ctrl+R"), this);
connect(sc, &QShortcut::activated, this, &BrowserTab::on_refresh_button_clicked);
}
{
connect(this->ui->search_box, &SearchBox::searchNext, this, &BrowserTab::on_search_next_clicked);
connect(this->ui->search_box, &SearchBox::searchPrev, this, &BrowserTab::on_search_previous_clicked);
}
{
QShortcut * sc = new QShortcut(QKeySequence("Escape"), this->ui->search_bar);
connect(sc, &QShortcut::activated, this, &BrowserTab::on_close_search_clicked);
}
FavouritePopup * popup = new FavouritePopup(this->ui->fav_button, this);
connect(popup, &FavouritePopup::unfavourited, this, [this]() {
this->ui->fav_button->setChecked(false);
kristall::globals().favourites.removeUrl(this->current_location);
});
this->ui->fav_button->setPopupMode(QToolButton::DelayedPopup);
this->ui->fav_button->setMenu(popup);
connect(popup, &FavouritePopup::newGroupClicked, this, [this, popup]() {
// Dialog to create new group
QString v = this->mainWindow->newGroupDialog();
// Update combobox
popup->fav_group->clear();
QStringList groups = kristall::globals().favourites.groups();
for (int i = 0; i < groups.length(); ++i)
{
popup->fav_group->addItem(groups[i]);
// Select this group if it is current one
if (!v.isEmpty() && groups[i] == v)
{
popup->fav_group->setCurrentIndex(i);
}
}
// Show the menu again
this->ui->fav_button->showMenu();
});
connect(popup->fav_group, QOverload<int>::of(&QComboBox::currentIndexChanged),
[this, popup](int index)
{
if (!popup->is_ready || index == -1) return;
// Change favourite's current group
kristall::globals().favourites.editFavouriteGroup(this->current_location,
popup->fav_group->currentText());
});
refreshOptionalToolbarItems();
refreshToolbarIcons();
setAcceptDrops(true);
}
BrowserTab::~BrowserTab()
{
this->current_handler->cancelRequest();
delete ui;
}
void BrowserTab::navigateTo(const QUrl &url, PushToHistory mode, RequestFlags flags)
{
if (kristall::globals().protocols.isSchemeSupported(url.scheme()) != ProtocolSetup::Enabled)
{
QMessageBox::warning(this, tr("Kristall"), tr("URI scheme not supported or disabled: ") + url.scheme());
return;
}
if ((this->current_handler != nullptr) and not this->current_handler->cancelRequest())
{
QMessageBox::warning(this, tr("Kristall"), tr("Failed to cancel running request!"));
return;
}
// If this page is in cache, store the scroll position
if (auto pg = kristall::globals().cache.find(this->current_location); pg != nullptr)
{
pg->scroll_pos = this->ui->text_browser->verticalScrollBar()->value();
}
this->redirection_count = 0;
this->successfully_loaded = false;
this->timer.start();
if(!this->lazy_loading && not this->startRequest(url, ProtocolHandler::Default, flags)) {
QMessageBox::critical(this, tr("Kristall"), tr("Failed to execute request to %1").arg(url.toString()));
return;
}
if(mode == PushImmediate) {
pushToHistory(url);
}
this->updateUI();
this->ui->text_browser->setFocus();
}
void BrowserTab::navigateBack(const QModelIndex &history_index)
{
auto url = history.get(history_index);
if (url.isValid())
{
current_history_index = history_index;
navigateTo(url, DontPush, RequestFlags::NavigatedBackOrForward);
}
}
void BrowserTab::navOneBackward()
{
navigateBack(history.oneBackward(current_history_index));
}
void BrowserTab::navOneForward()
{
navigateBack(history.oneForward(current_history_index));
}
void BrowserTab::navigateToRoot()
{
if(this->current_location.scheme() == "about") return;
QUrl url = this->current_location;
url.setPath("/");
navigateTo(url, BrowserTab::PushImmediate);
}
void BrowserTab::navigateToParent()
{
if(this->current_location.scheme() == "about") return;
QUrl url = this->current_location;
// Make sure we have a trailing slash, or else
// QUrl::resolved will not work
if (!url.path().endsWith("/"))
{
url.setPath(url.path() + "/");
}
// Go up one directory
url = url.resolved(QUrl{".."});
navigateTo(url, BrowserTab::PushImmediate);
}
void BrowserTab::scrollToAnchor(QString const &anchor)
{
qDebug() << "scroll to anchor" << anchor;
this->ui->text_browser->scrollToAnchor(anchor);
}
void BrowserTab::reloadPage()
{
lazy_loading = false;
if (current_location.isValid())
this->navigateTo(this->current_location, DontPush, RequestFlags::DontReadFromCache);
}
void BrowserTab::focusUrlBar()
{
this->ui->url_bar->setFocus(Qt::ShortcutFocusReason);
this->ui->url_bar->selectAll();
}
void BrowserTab::focusSearchBar()
{
if(not this->ui->search_bar->isVisible()) {
this->ui->search_box->setText("");
}
this->ui->search_bar->setVisible(true);
this->ui->search_box->setFocus();
this->ui->search_box->selectAll();
}
void BrowserTab::openSourceView()
{
QFont monospace_font("monospace");
monospace_font.setStyleHint(QFont::Monospace);
auto dialog = std::make_unique<QDialog>(this, Qt::WindowTitleHint | Qt::WindowSystemMenuHint);
dialog->setWindowTitle(tr("Source of %0").arg(this->current_location.toString()));
auto layout = new QVBoxLayout(dialog.get());
dialog->setLayout(layout);
auto hint = new QLabel(dialog.get());
hint->setText(tr("Mime type: %0").arg(current_mime.toString()));
layout->addWidget(hint);
auto text = new QPlainTextEdit(dialog.get());
text->setPlainText(QString::fromUtf8(current_buffer));
text->setReadOnly(true);
text->setFont(monospace_font);
text->setWordWrapMode(QTextOption::NoWrap);
layout->addWidget(text);
auto buttons = new QDialogButtonBox(dialog.get());
buttons->setStandardButtons(QDialogButtonBox::Ok);
layout->addWidget(buttons);
connect(buttons->button(QDialogButtonBox::Ok), &QPushButton::pressed, dialog.get(), &QDialog::accept);
dialog->resize(640, 480);
dialog->exec();
}
void BrowserTab::on_url_bar_returnPressed()
{
QString urltext = this->ui->url_bar->text().trimmed();
// Expand '~' to user's home directory.
static const QString PREFIX_HOME = "file://~";
if (urltext.startsWith(PREFIX_HOME))
urltext = "file://" + QDir::homePath() + urltext.remove(0, PREFIX_HOME.length());
QUrl url { urltext };
if (url.scheme().isEmpty())
{
// Need this to get the validation below to work.
url.setUrl("internal://" + this->ui->url_bar->text());
// We check if there is at least a TLD so that single words
// are assumed to be searches.
if (url.isValid() && url.host().contains("."))
{
url = QUrl{"gemini://" + urltext};
}
else
{
// Use the text as a search query.
if (kristall::globals().options.search_engine.isEmpty() ||
!kristall::globals().options.search_engine.contains("%1"))
{
QMessageBox::warning(this,
tr("Kristall"),
tr("No search engine is configured.\n"
"Please configure one in the settings to allow searching via the URL bar.\n\n"
"See the Help menu for additional information.")
);
return;
}
url = QUrl{QString(kristall::globals().options.search_engine)
.arg(this->ui->url_bar->text())};
}
}
this->ui->url_bar->clearFocus();
this->navigateTo(url, PushImmediate);
}
void BrowserTab::on_url_bar_escapePressed()
{
this->setUrlBarText(this->current_location.toString(QUrl::FullyEncoded));
}
void BrowserTab::on_url_bar_focused()
{
this->updateUrlBarStyle();
}
void BrowserTab::on_url_bar_blurred()
{
this->updateUrlBarStyle();
}
void BrowserTab::on_refresh_button_clicked()
{
this->reloadPage();
}
void BrowserTab::on_root_button_clicked()
{
this->navigateToRoot();
}
void BrowserTab::on_parent_button_clicked()
{
this->navigateToParent();
}
void BrowserTab::on_networkError(ProtocolHandler::NetworkError error_code, const QString &reason)
{
this->network_timeout_timer.stop();
QString file_name;
switch(error_code)
{
case ProtocolHandler::UnknownError: file_name = "UnknownError.gemini"; break;
case ProtocolHandler::ProtocolViolation: file_name = "ProtocolViolation.gemini"; break;
case ProtocolHandler::HostNotFound: file_name = "HostNotFound.gemini"; break;
case ProtocolHandler::ConnectionRefused: file_name = "ConnectionRefused.gemini"; break;
case ProtocolHandler::ResourceNotFound: file_name = "ResourceNotFound.gemini"; break;
case ProtocolHandler::BadRequest: file_name = "BadRequest.gemini"; break;
case ProtocolHandler::ProxyRequest: file_name = "ProxyRequest.gemini"; break;
case ProtocolHandler::InternalServerError: file_name = "InternalServerError.gemini"; break;
case ProtocolHandler::InvalidClientCertificate: file_name = "InvalidClientCertificate.gemini"; break;
case ProtocolHandler::UntrustedHost: file_name = "UntrustedHost.gemini"; break;
case ProtocolHandler::MistrustedHost: file_name = "MistrustedHost.gemini"; break;
case ProtocolHandler::Unauthorized: file_name = "Unauthorized.gemini"; break;
case ProtocolHandler::TlsFailure: file_name = "TlsFailure.gemini"; break;
case ProtocolHandler::Timeout: file_name = "Timeout.gemini"; break;
}
file_name = ":/error_page/" + file_name;
QFile file_src { file_name };
if(not file_src.open(QFile::ReadOnly)) {
assert(false);
}
auto contents = QString::fromUtf8(file_src.readAll()).arg(reason).toUtf8();
this->is_internal_location = true;
this->on_requestComplete(
contents,
"text/gemini");
this->updateUI();
}
void BrowserTab::on_networkTimeout()
{
if(this->current_handler != nullptr) {
this->current_handler->cancelRequest();
}
on_networkError(ProtocolHandler::Timeout, tr("The server didn't respond in time."));
}
void BrowserTab::on_focusSearchbar()
{
this->focusSearchBar();
}
void BrowserTab::on_certificateRequired(const QString &reason)
{
this->network_timeout_timer.stop();
if (not trySetClientCertificate(reason))
{
setErrorMessage(tr("The page requested a authorized client certificate, but none was provided.\r\nOriginal query was: %1").arg(reason));
}
else
{
this->navigateTo(this->current_location, DontPush);
}
this->updateUI();
}
void BrowserTab::on_hostCertificateLoaded(const QSslCertificate &cert)
{
this->current_server_certificate = cert;
}
static QByteArray convertToUtf8(QByteArray const & input, QString const & charSet)
{
auto charset_u8 = charSet.toUpper().toUtf8();
// TRANSLIT will try to mix-match other code points to reflect to correct encoding
iconv_t cd = iconv_open("UTF-8", charset_u8.data());
if(cd == (iconv_t)-1) {
return QByteArray { };
}
QByteArray result;
char temp_buffer[4096];
#if defined(__NetBSD__)
char const * input_ptr = reinterpret_cast<char const *>(input.data());
#else
char * input_ptr = const_cast<char *>(reinterpret_cast<char const *>(input.data()));
#endif
size_t input_size = input.size();
while(input_size > 0)
{
char * out_ptr = temp_buffer;
size_t out_size = sizeof(temp_buffer);
size_t n = iconv(cd, &input_ptr, &input_size, &out_ptr, &out_size);
if (n == size_t(-1))
{
if(errno == E2BIG) {
// silently ignore E2BIG, as we will continue conversion in the next loop
}
else if(errno == EILSEQ) {
// this is an invalid multibyte sequence.
// append an "replacement character" and skip a byte
if(input_size > 0) {
input_size --;
input_ptr++;
result.append(u8"<EFBFBD>");
}
}
else if(errno == EINVAL) {
// the file ends with an invalid multibyte sequence.
// just drop it and display the replacement-character
if(input_size > 0) {
input_size --;
input_ptr++;
result.append(u8"<EFBFBD>");
}
}
else {
perror("iconv conversion error");
break;
}
}
size_t len = out_ptr - temp_buffer;
result.append(temp_buffer, len);
}
iconv_close(cd);
return result;
}
void BrowserTab::on_requestComplete(const QByteArray &ref_data, const QString &mime_text)
{
MimeType mime = MimeParser::parse(mime_text);
this->on_requestComplete(ref_data, mime);
}
void BrowserTab::on_requestComplete(const QByteArray &ref_data, const MimeType &mime)
{
QByteArray data;
this->ui->media_browser->stopPlaying();
this->network_timeout_timer.stop();
qDebug() << "Loaded" << ref_data.length() << "bytes of type" << mime.type << "/" << mime.subtype;
// for(auto & key : mime.parameters.keys()) {
// qDebug() << key << mime.parameters[key];
// }
auto charset = mime.parameter("charset", "utf-8").toUpper();
if(not ref_data.isEmpty() and (mime.type == "text") and (charset != "UTF-8"))
{
auto temp = convertToUtf8(ref_data, charset);
bool ok = (temp.size() > 0);
if(ok) {
data = std::move(temp);
} else {
auto response = QMessageBox::question(
this,
tr("Kristall"),
tr("Failed to convert input charset %1 to UTF-8. Cannot display the file.\r\nDo you want to display unconverted data anyways?").arg(charset)
);
if(response != QMessageBox::Yes) {
setErrorMessage(tr("Failed to convert input charset %1 to UTF-8.").arg(charset));
return;
}
}
}
else
{
data = ref_data;
}
this->successfully_loaded = true;
this->page_title = "";
renderPage(data, mime);
if(this->navigate_to_fragment)
{
// Implement navigation semantics:
QString fragment = this->current_location.fragment();
if(not fragment.isEmpty()) {
this->scrollToAnchor(fragment);
}
}
this->updatePageTitle();
this->updateUrlBarStyle();
this->current_stats.file_size = ref_data.size();
this->current_stats.mime_type = mime;
this->current_stats.loading_time = this->timer.elapsed();
this->current_stats.loaded_from_cache = was_read_from_cache;
emit this->fileLoaded(this->current_stats);
this->updateMouseCursor(false);
emit this->requestStateChanged(RequestState::None);
this->request_state = RequestState::None;
}
void BrowserTab::renderPage(const QByteArray &data, const MimeType &mime)
{
this->current_mime = mime;
this->current_buffer = data;
this->graphics_scene.clear();
this->ui->text_browser->setText("");
ui->text_browser->setStyleSheet("");
enum DocumentType
{
Text,
Image,
Media
};
DocumentType doc_type = Text;
std::unique_ptr<QTextDocument> document;
this->outline.clear();
auto doc_style = kristall::globals().document_style.derive(this->current_location);
this->ui->text_browser->setStyleSheet(QString("QTextBrowser { background-color: %1; color: %2; }").arg(doc_style.background_color.name(), doc_style.standard_color.name()));
bool plaintext_only = (kristall::globals().options.text_display == GenericSettings::PlainText);
// Only cache text pages
bool will_cache = true;
if (not plaintext_only and mime.is("text", "gemini"))
{
document = GeminiRenderer::render(
data,
this->current_location,
doc_style,
this->outline,
this->page_title);
}
else if (not plaintext_only and mime.is("text","gophermap"))
{
document = GophermapRenderer::render(
data,
this->current_location,
doc_style);
}
else if (not plaintext_only and mime.is("text","html") or mime.is("application","xhtml+xml"))
{
document = HtmlRenderer::render(
data,
this->current_location,
doc_style,
this->outline,
this->page_title);
}
else if (not plaintext_only and mime.is("text","x-kristall-theme"))
{
// ugly workaround for QSettings needing a file
QFile temp_file { kristall::globals().dirs.cache_root.absoluteFilePath("preview-theme.kthm") };
if(temp_file.open(QFile::WriteOnly)) {
IoUtil::writeAll(temp_file, data);
temp_file.close();
}
QSettings temp_settings {
temp_file.fileName(),
QSettings::IniFormat
};
DocumentStyle preview_style;
preview_style.load(temp_settings);
QFile src { ":/about/style-preview.gemini" };
src.open(QFile::ReadOnly);
document = GeminiRenderer::render(
src.readAll(),
this->current_location,
preview_style,
this->outline,
this->page_title);
this->ui->text_browser->setStyleSheet(QString("QTextBrowser { background-color: %1; color: %2; }")
.arg(preview_style.background_color.name(), preview_style.standard_color.name()));
will_cache = false;
}
else if (not plaintext_only and mime.is("text","markdown"))
{
document = MarkdownRenderer::render(
data,
this->current_location,
doc_style,
this->outline,
this->page_title);
}
else if (mime.is("text"))
{
document = PlainTextRenderer::render(data, doc_style);
}
else if (mime.is("image"))
{
doc_type = Image;
QBuffer buffer;
buffer.setData(data);
QImageReader reader{&buffer};
reader.setAutoTransform(true);
reader.setAutoDetectImageFormat(true);
auto pixmap = QPixmap::fromImageReader(&reader);
if (pixmap.isNull())
{
doc_type = Text;
QString error_data = tr("Failed to load picture: %1").arg(reader.errorString());
if (plaintext_only)
document = PlainTextRenderer::render(error_data.toUtf8(), doc_style);
else
document = GeminiRenderer::render(
error_data.toUtf8(),
this->current_location,
doc_style,
this->outline,
this->page_title);
}
else
{
this->graphics_scene.addPixmap(pixmap);
this->graphics_scene.setSceneRect(pixmap.rect());
this->ui->graphics_browser->setScene(&graphics_scene);
connect(&graphics_scene, &QGraphicsScene::changed, this, [=]() {
QSize imageSize = pixmap.size();
QSize browserSize = this->ui->graphics_browser->sizeHint();
if (imageSize.width() > browserSize.width() || imageSize.height() > browserSize.height())
this->ui->graphics_browser->fitInView(graphics_scene.sceneRect(), Qt::KeepAspectRatio);
else
this->ui->graphics_browser->resetTransform();
this->ui->graphics_browser->setSceneRect(graphics_scene.sceneRect());
});
}
will_cache = false;
}
else if (mime.is("video") or mime.is("audio"))
{
doc_type = Media;
this->ui->media_browser->setMedia(data, this->current_location, mime.type);
will_cache = false;
}
else
{
QString page_data = tr("Unsupported Media Type!\n"
"\n"
"Kristall cannot display the requested document\n"
"To view this media, use the File menu to save it to your local drive, then open the saved file in another program that can display the document for you.\n\n"
"Details:\n"
"- MIME type: %1/%2\n"
"- Size: %3\n").arg(mime.type, mime.subtype, IoUtil::size_human(data.size()));
if (plaintext_only)
document = PlainTextRenderer::render(page_data.toUtf8(), doc_style);
else
document = GeminiRenderer::render(
page_data.toUtf8(),
this->current_location,
doc_style,
this->outline,
this->page_title);
will_cache = false;
}
assert((document != nullptr) == (doc_type == Text));
this->ui->text_browser->setVisible(doc_type == Text);
this->ui->graphics_browser->setVisible(doc_type == Image);
this->ui->media_browser->setVisible(doc_type == Media);
this->ui->text_browser->setDocument(document.get());
this->current_document = std::move(document);
this->current_style = std::move(doc_style);
this->updatePageMargins();
this->needs_rerender = false;
emit this->locationChanged(this->current_location);
this->updateUI();
this->updateUrlBarStyle();
// Put file in cache if we are not in an internal
// location. Don't cache if we read this page from cache.
// We also do not cache if user has a client certificate enabled.
if (will_cache &&
!this->is_internal_location &&
!this->was_read_from_cache &&
!this->current_identity.isValid())
{
kristall::globals().cache.push(this->current_location, data, mime);
}
}
void BrowserTab::rerenderPage()
{
auto scroll = this->ui->text_browser->verticalScrollBar()->value();
this->renderPage(this->current_buffer, this->current_mime);
// Restore scroll position
this->ui->text_browser->verticalScrollBar()->setValue(scroll);
}
void BrowserTab::updatePageTitle()
{
if (page_title.isEmpty())
{
// Use document filename as title instead.
page_title = this->current_location.path();
auto parts = page_title.split("/");
page_title = parts[parts.length() - 1];
if (page_title.isEmpty())
{
// Just use the hostname if we can't find anything else
page_title = this->current_location.host();
}
}
// This will strip new-line characters from the title, in case
// there are any.
static const QRegularExpression NL_REGEX = QRegularExpression("\n");
page_title.replace(NL_REGEX, "");
page_title = page_title.trimmed();
emit this->titleChanged(this->page_title);
}
void BrowserTab::on_inputRequired(const QString &query, const bool is_sensitive)
{
this->network_timeout_timer.stop();
QueryDialog dialog(this);
dialog.setLabelText(query);
if (is_sensitive) dialog.setTextEchoMode(QLineEdit::Password);
while(true)
{
if (dialog.exec() != QDialog::Accepted)
{
setErrorMessage(tr("Site requires input:\n%1").arg(query));
return;
}
QUrl new_location = current_location;
new_location.setQuery(dialog.textValue());
int len = new_location.toString(QUrl::FullyEncoded).toUtf8().size();
if(len >= 1020) {
QMessageBox::warning(
this,
tr("Kristall"),
tr("Your input message is too long. Your input is %1 bytes, but a maximum of %2 bytes are allowed.\r\nPlease cancel or shorten your input.").arg(len).arg(1020)
);
} else {
this->navigateTo(new_location, DontPush);
break;
}
}
}
void BrowserTab::on_redirected(QUrl uri, bool is_permanent)
{
Q_UNUSED(is_permanent);
this->network_timeout_timer.stop();
// #79: Handle non-full url redirects
if (uri.isRelative())
{
uri.setScheme(current_location.scheme());
uri.setHost(current_location.host());
uri.setPort(current_location.port());
}
if (redirection_count >= kristall::globals().options.max_redirections)
{
setErrorMessage(tr("Too many consecutive redirections. The last redirection would have redirected you to:\r\n%1").arg(uri.toString(QUrl::FullyEncoded)));
return;
}
else
{
bool is_cross_protocol = (this->current_location.scheme() != uri.scheme());
bool is_cross_host = (this->current_location.host() != uri.host());
QString question;
if(kristall::globals().options.redirection_policy == GenericSettings::WarnAlways)
{
question = QString(
tr("The location you visited wants to redirect you to another location:\r\n"
"%1\r\n"
"Do you want to allow the redirection?")
).arg(uri.toString(QUrl::FullyEncoded));
}
else if((kristall::globals().options.redirection_policy & (GenericSettings::WarnOnHostChange | GenericSettings::WarnOnSchemeChange)) and is_cross_protocol and is_cross_host)
{
question = QString(
tr("The location you visited wants to redirect you to another host and switch the protocol.\r\n"
"Protocol: %1\r\n"
"New Host: %2\r\n"
"Do you want to allow the redirection?")
).arg(uri.scheme()).arg(uri.host());
}
else if((kristall::globals().options.redirection_policy & GenericSettings::WarnOnSchemeChange) and is_cross_protocol)
{
question = QString(
tr("The location you visited wants to switch the protocol.\r\n"
"Protocol: %1\r\n"
"Do you want to allow the redirection?")
).arg(uri.scheme());
}
else if((kristall::globals().options.redirection_policy & GenericSettings::WarnOnHostChange) and is_cross_host)
{
question = QString(
tr("The location you visited wants to redirect you to another host.\r\n"
"New Host: %1\r\n"
"Do you want to allow the redirection?")
).arg(uri.host());
}
if (!question.isEmpty())
{
auto answer = QMessageBox::question(
this,
tr("Kristall"),
question
);
if(answer != QMessageBox::Yes) {
setErrorMessage(tr("Redirection to %1 cancelled by user").arg(uri.toString()));
return;
}
}
if (this->startRequest(uri, ProtocolHandler::Default))
{
redirection_count += 1;
this->current_location = uri;
this->setUrlBarText(uri.toString(QUrl::FullyEncoded));
this->history.replaceUrl(this->current_history_index.row(), uri);
}
else
{
setErrorMessage(tr("Redirection to %1 failed").arg(uri.toString()));
}
}
}
void BrowserTab::setErrorMessage(const QString &msg)
{
this->is_internal_location = true;
this->on_requestComplete(
tr("An error happened:\r\n%0").arg(msg).toUtf8(),
"text/plain charset=utf-8");
this->updateUI();
}
void BrowserTab::pushToHistory(const QUrl &url)
{
this->current_history_index = this->history.pushUrl(this->current_history_index, url);
this->updateUI();
}
void BrowserTab::showFavouritesPopup()
{
// We add it to favourites immediately.
kristall::globals().favourites.addUnsorted(this->current_location, this->page_title);
const Favourite fav = kristall::globals().favourites.getFavourite(this->current_location);
this->ui->fav_button->setChecked(true);
FavouritePopup *popup = static_cast<FavouritePopup*>(this->ui->fav_button->menu());
// Prepare menu:
popup->is_ready = false;
{
// Setup the group combobox
popup->fav_group->setCurrentIndex(-1);
popup->fav_group->clear();
QStringList groups = kristall::globals().favourites.groups();
for (int i = 0; i < groups.length(); ++i)
{
popup->fav_group->addItem(groups[i]);
// Set combobox index to current group
if (groups[i] == kristall::globals().favourites.groupForFavourite(fav.destination))
{
popup->fav_group->setCurrentIndex(i);
}
}
}
popup->fav_title->setText(fav.title.isEmpty()
? fav.destination.toString(QUrl::FullyEncoded)
: fav.title);
popup->setFocus(Qt::PopupFocusReason);
popup->fav_title->setFocus(Qt::PopupFocusReason);
popup->fav_title->selectAll();
popup->is_ready = true;
// Show menu, this will block thread
this->ui->fav_button->showMenu();
// Update the favourites entry with what user inputted into menu
kristall::globals().favourites.editFavouriteTitle(this->current_location, popup->fav_title->text());
}
void BrowserTab::on_fav_button_clicked()
{
this->showFavouritesPopup();
}
void BrowserTab::on_text_browser_anchorClicked(const QUrl &url, bool open_in_new_tab)
{
// Ctrl scheme is *always* the current tab, it's
// used for fake-buttons
if(url.scheme() == "kristall+ctrl")
{
bool is_theme_preview = this->current_mime.is("text", "x-kristall-theme");
if(this->is_internal_location or is_theme_preview) {
QString opt = url.path();
qDebug() << "kristall control action" << opt;
// this will bypass the TLS security
if(not is_theme_preview and opt == "ignore-tls") {
auto response = QMessageBox::question(
this,
tr("Kristall"),
tr("This sites certificate could not be verified! This may be a man-in-the-middle attack on the server to send you malicious content (or the server admin made a configuration mistake).\r\nAre you sure you want to continue?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No
);
if(response == QMessageBox::Yes) {
this->startRequest(this->current_location, ProtocolHandler::IgnoreTlsErrors);
}
}
//
else if(not is_theme_preview and opt == "ignore-tls-safe") {
this->startRequest(this->current_location, ProtocolHandler::IgnoreTlsErrors);
}
// Add this page to the list of trusted hosts and continue
else if(not is_theme_preview and opt == "add-fingerprint") {
auto answer = QMessageBox::question(
this,
tr("Kristall"),
tr("Do you really want to add the server certificate to your list of trusted hosts?\r\nHost: %1")
.arg(this->current_location.host()),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::Yes // that's a sane option here
);
if(answer != QMessageBox::Yes) {
return;
}
if(this->current_location.scheme() == "gemini") {
kristall::globals().trust.gemini.addTrust(this->current_location, this->current_server_certificate);
}
else if(this->current_location.scheme() == "https") {
kristall::globals().trust.https.addTrust(this->current_location, this->current_server_certificate);
}
else {
assert(false and "missing protocol implementation!");
}
this->startRequest(this->current_location, ProtocolHandler::Default);
}
else if(opt == "install-theme") {
if(is_theme_preview)
{
// ugly workaround for QSettings needing a file
QFile temp_file { kristall::globals().dirs.cache_root.absoluteFilePath("preview-theme.kthm") };
if(temp_file.open(QFile::WriteOnly)) {
IoUtil::writeAll(temp_file, this->current_buffer);
temp_file.close();
}
QSettings temp_settings {
temp_file.fileName(),
QSettings::IniFormat
};
QString name;
if(auto name_var = temp_settings.value("name"); name_var.isNull())
{
QInputDialog input { this };
input.setInputMode(QInputDialog::TextInput);
input.setLabelText(tr("This style has no embedded name. Please enter a name for the preset:"));
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
input.setTextValue(this->current_location.fileName().split(".", QString::SkipEmptyParts).first());
#else
input.setTextValue(this->current_location.fileName().split(".", Qt::SkipEmptyParts).first());
#endif
if(input.exec() != QDialog::Accepted)
return;
name = input.textValue().trimmed();
}
else
{
name = name_var.toString();
}
auto answer = QMessageBox::question(
this,
tr("Kristall"),
tr("Do you want to add the style %1 to your collection?").arg(name)
);
if(answer != QMessageBox::Yes)
return;
QString fileName;
int index = 0;
do
{
fileName = DocumentStyle::createFileNameFromName(name, index);
index += 1;
} while(kristall::globals().dirs.styles.exists(fileName));
QFile target_file { kristall::globals().dirs.styles.absoluteFilePath(fileName) };
if(target_file.open(QFile::WriteOnly)) {
IoUtil::writeAll(target_file, this->current_buffer);
target_file.close();
}
QSettings final_settings {
target_file.fileName(),
QSettings::IniFormat
};
final_settings.setValue("name", name);
final_settings.sync();
QMessageBox::information(
this,
tr("Kristall"),
tr("The theme %1 was successfully added to your theme collection!").arg(name)
);
}
else
{
qDebug() << "install-theme triggered from non-theme document!";
}
}
} else {
QMessageBox::critical(
this,
tr("Kristall"),
tr("Malicious site detected! This site tries to use the Kristall control scheme!\r\nA trustworthy site does not do this!").arg(this->current_location.host())
);
}
return;
}
QUrl real_url = url;
if (real_url.isRelative())
real_url = this->current_location.resolved(url);
auto support = kristall::globals().protocols.isSchemeSupported(real_url.scheme());
if (support == ProtocolSetup::Enabled)
{
if(open_in_new_tab) {
mainWindow->addNewTab(false, real_url);
} else {
this->navigateTo(real_url, PushImmediate);
}
}
else
{
if (kristall::globals().options.use_os_scheme_handler)
{
if (not QDesktopServices::openUrl(url))
{
QMessageBox::warning(this, "Kristall", tr("Failed to start system URL handler for\r\n%1").arg(real_url.toString()));
}
}
else if (support == ProtocolSetup::Disabled)
{
QMessageBox::warning(this, "Kristall", tr("The requested url uses a scheme that has been disabled in the settings:\r\n%1").arg(real_url.toString()));
}
else
{
QMessageBox::warning(this, "Kristall", tr("The requested url cannot be processed by Kristall:\r\n%1").arg(real_url.toString()));
}
}
}
void BrowserTab::on_text_browser_highlighted(const QUrl &url)
{
if (url.isValid() and not (url.scheme() == "kristall+ctrl"))
{
QUrl real_url = url;
if (real_url.isRelative())
real_url = this->current_location.resolved(url);
this->mainWindow->setUrlPreview(real_url);
}
else
{
this->mainWindow->setUrlPreview(QUrl{});
}
}
void BrowserTab::on_stop_button_clicked()
{
if(this->current_handler != nullptr) {
this->current_handler->cancelRequest();
}
this->updateUI();
}
void BrowserTab::on_home_button_clicked()
{
this->navigateTo(QUrl(kristall::globals().options.start_page), BrowserTab::PushImmediate);
}
void BrowserTab::on_requestProgress(qint64 transferred)
{
this->current_stats.file_size = transferred;
this->current_stats.mime_type = MimeType { };
this->current_stats.loading_time = this->timer.elapsed();
this->current_stats.loaded_from_cache = false;
emit this->fileLoaded(this->current_stats);
this->network_timeout_timer.stop();
this->network_timeout_timer.start(kristall::globals().options.network_timeout);
}
void BrowserTab::on_back_button_clicked()
{
navOneBackward();
}
void BrowserTab::on_forward_button_clicked()
{
navOneForward();
}
void BrowserTab::updateUI()
{
this->ui->back_button->setEnabled(history.oneBackward(current_history_index).isValid());
this->ui->forward_button->setEnabled(history.oneForward(current_history_index).isValid());
bool in_progress = (this->current_handler != nullptr) and this->current_handler->isInProgress();
this->ui->refresh_button->setVisible(not in_progress);
this->ui->stop_button->setVisible(in_progress);
this->refreshFavButton();
}
void BrowserTab::refreshFavButton()
{
this->ui->fav_button->setEnabled(this->successfully_loaded);
this->ui->fav_button->setChecked(kristall::globals().favourites.containsUrl(this->current_location));
}
void BrowserTab::setUrlBarText(const QString & text)
{
this->ui->url_bar->setText(text);
this->updateUrlBarStyle();
}
void BrowserTab::updateUrlBarStyle()
{
// https://stackoverflow.com/a/14424003
const auto setLineEditTextFormat =
[](QLineEdit* l, const QList<QTextLayout::FormatRange>& f)
{
if (!l) return;
QList<QInputMethodEvent::Attribute> attr;
foreach (const QTextLayout::FormatRange& fr, f)
{
attr.append(QInputMethodEvent::Attribute(
QInputMethodEvent::TextFormat,
fr.start - l->cursorPosition(),
fr.length,
fr.format));
}
QInputMethodEvent event(QString(), attr);
QCoreApplication::sendEvent(l, &event);
};
QUrl url { this->ui->url_bar->text().trimmed() };
// Set all text to default colour if url bar
// is focused, is at an about: location,
// or has an invalid URL.
if (!kristall::globals().options.fancy_urlbar ||
this->ui->url_bar->hasFocus() ||
!url.isValid() ||
this->current_location.scheme() == "about")
{
// Disable styling
if (!this->no_url_style)
{
setLineEditTextFormat(this->ui->url_bar,
QList<QTextLayout::FormatRange>());
this->no_url_style = true;
}
return;
}
this->no_url_style = false;
// Styling enabled: 'authority' (hostname, port, etc) of
// the URL is highlighted (i.e default colour),
// the rest is in grey-ish colour
//
// Example:
//
// gemini://an.example.com:1965/index.gmi
// ^-------^
// grey ^-----------------^
// default
// ^--------^
// grey
QList<QTextLayout::FormatRange> formats;
// We only need to create one style, which is the
// non-authority colour text (grey-ish, determined by theme in kristall:setTheme)
// The rest of the text is in default theme foreground colour.
QTextCharFormat f;
f.setForeground(kristall::globals().options.fancy_urlbar_dim_colour);
// Create format range for left-side of URL
QTextLayout::FormatRange fr_left;
fr_left.start = 0;
fr_left.length = url.scheme().length() + strlen("://");
fr_left.format = f;
formats.append(fr_left);
// Create format range for right-side of URL (if we have one)
if (url.scheme() != "file" && !url.path().isEmpty())
{
QTextLayout::FormatRange fr_right;
fr_right.start = fr_left.length + url.authority().length();
fr_right.length = url.toString(QUrl::FullyEncoded).length() - fr_right.start;
fr_right.format = f;
formats.append(fr_right);
}
// Finally, apply the colour formatting.
setLineEditTextFormat(this->ui->url_bar, formats);
}
void BrowserTab::setUiDensity(UIDensity density)
{
switch (density)
{
case UIDensity::compact:
{
this->ui->layout_main->setContentsMargins(0, 0, 0, 0);
this->ui->layout_toolbar->setContentsMargins(8, 0, 8, 0);
} break;
case UIDensity::classic:
{
this->ui->layout_main->setContentsMargins(0, 9, 0, 9);
this->ui->layout_toolbar->setContentsMargins(18, 9, 18, 9);
} break;
}
}
void BrowserTab::updatePageMargins()
{
if (!this->current_document || !this->current_style.text_width_enabled)
return;
QTextFrame *root = this->current_document->rootFrame();
QTextFrameFormat fmt = root->frameFormat();
int margin = std::max((this->width() - this->current_style.text_width) / 2,
this->current_style.margin_h);
fmt.setLeftMargin(margin);
fmt.setRightMargin(margin);
root->setFrameFormat(fmt);
this->ui->text_browser->setDocument(this->current_document.get());
}
void BrowserTab::refreshOptionalToolbarItems()
{
this->ui->home_button->setVisible(kristall::globals().options.enable_home_btn);
this->ui->root_button->setVisible(kristall::globals().options.enable_root_btn);
this->ui->parent_button->setVisible(kristall::globals().options.enable_parent_btn);
}
void BrowserTab::refreshToolbarIcons()
{
const QString ICO_NAMES[] = {
"light",
"dark"
};
QString ico_name = ICO_NAMES[(int)kristall::globals().options.explicit_icon_theme];
// Favourites button icons
QIcon ico_fav;
QPixmap p_fav_on (":/icons/" + ico_name + "/actions/favourite-on.svg");
QPixmap p_fav_off(":/icons/" + ico_name + "/actions/favourite-off.svg");
ico_fav.addPixmap(p_fav_on, QIcon::Normal, QIcon::On);
ico_fav.addPixmap(p_fav_off, QIcon::Normal, QIcon::Off);
// Certificates button icons
QIcon ico_cert;
QPixmap p_cert_on (":/icons/" + ico_name + "/actions/certificate-on.svg");
QPixmap p_cert_off(":/icons/" + ico_name + "/actions/certificate-off.svg");
ico_cert.addPixmap(p_cert_on, QIcon::Normal, QIcon::On);
ico_cert.addPixmap(p_cert_off, QIcon::Normal, QIcon::Off);
this->ui->fav_button->setIcon(ico_fav);
this->ui->enable_client_cert_button->setIcon(ico_cert);
}
bool BrowserTab::trySetClientCertificate(const QString &query)
{
CertificateSelectionDialog dialog{this};
dialog.setServerQuery(query);
if (dialog.exec() != QDialog::Accepted)
{
this->disableClientCertificate();
return false;
}
return this->enableClientCertificate(dialog.identity());
}
void BrowserTab::resetClientCertificate()
{
if (this->current_identity.isValid() and not this->current_identity.is_persistent)
{
auto respo = QMessageBox::question(this, "Kristall", tr("You currently have a transient session active!\r\nIf you disable the session, you will not be able to restore it. Continue?"));
if (respo != QMessageBox::Yes)
{
this->ui->enable_client_cert_button->setChecked(true);
return;
}
}
this->disableClientCertificate();
}
void BrowserTab::addProtocolHandler(std::unique_ptr<ProtocolHandler> &&handler)
{
connect(handler.get(), &ProtocolHandler::requestProgress, this, &BrowserTab::on_requestProgress);
connect(handler.get(), &ProtocolHandler::requestComplete, this,
qOverload<QByteArray const &, QString const &>(&BrowserTab::on_requestComplete));
connect(handler.get(), &ProtocolHandler::requestStateChange, this, [this](RequestState state) {
emit this->requestStateChanged(state);
this->request_state = state;
});
connect(handler.get(), &ProtocolHandler::redirected, this, &BrowserTab::on_redirected);
connect(handler.get(), &ProtocolHandler::inputRequired, this, &BrowserTab::on_inputRequired);
connect(handler.get(), &ProtocolHandler::networkError, this, &BrowserTab::on_networkError);
connect(handler.get(), &ProtocolHandler::certificateRequired, this, &BrowserTab::on_certificateRequired);
connect(handler.get(), &ProtocolHandler::hostCertificateLoaded, this, &BrowserTab::on_hostCertificateLoaded);
this->protocol_handlers.emplace_back(std::move(handler));
}
bool BrowserTab::startRequest(const QUrl &url, ProtocolHandler::RequestOptions options, RequestFlags flags)
{
this->updateMouseCursor(true);
this->current_server_certificate = QSslCertificate { };
this->was_read_from_cache = false;
this->current_handler = nullptr;
for(auto & ptr : this->protocol_handlers)
{
if(ptr->supportsScheme(url.scheme())) {
this->current_handler = ptr.get();
break;
}
}
assert((this->current_handler != nullptr) and "If this error happens, someone forgot to add a new protocol handler class in the constructor. Shame on the programmer!");
auto const try_enable_certificate = [&]() -> bool {
if(this->current_identity.isValid()) {
if(not this->current_handler->enableClientCertificate(this->current_identity)) {
auto answer = QMessageBox::question(
this,
"Kristall",
tr("You requested a %1-URL with a client certificate, but these are not supported for this scheme. Continue?").arg(url.scheme())
);
if(answer != QMessageBox::Yes)
return false;
this->disableClientCertificate();
}
} else {
this->disableClientCertificate();
}
return true;
};
if(not try_enable_certificate())
return false;
if(this->current_identity.isValid() and (url.host() != this->current_location.host())) {
auto answer = QMessageBox::question(
this,
"Kristall",
tr("You want to visit a new host, but have a client certificate enabled. This may be a risk to expose your identity to another host.\r\nDo you want to keep the certificate enabled?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No
);
if(answer != QMessageBox::Yes) {
this->disableClientCertificate();
}
}
if(this->current_identity.isValid() and this->current_identity.isHostFiltered(url)) {
auto answer = QMessageBox::question(
this,
"Kristall",
tr("Your client certificate has a host filter enabled and this site does not match the host filter.\r\n"
"New URL: %1\r\nHost Filter: %2\r\nDo you want to keep the certificate enabled?")
.arg(url.toString(QUrl::FullyEncoded | QUrl::RemoveFragment), this->current_identity.host_filter),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No
);
if(answer != QMessageBox::Yes) {
this->disableClientCertificate();
}
}
else if(not this->current_identity.isValid()) {
for(auto ident_ptr : kristall::globals().identities.allIdentities())
{
if(ident_ptr->isAutomaticallyEnabledOn(url)) {
auto answer = QMessageBox::question(
this,
"Kristall",
tr("An automatic client certificate was detected for this site:\r\n%1\r\nDo you want to enable that certificate?")
.arg(ident_ptr->display_name),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No
);
if(answer != QMessageBox::Yes) {
break;
}
enableClientCertificate(*ident_ptr);
break;
}
}
}
if(not try_enable_certificate())
return false;
QString urlstr = url.toString(QUrl::FullyEncoded);
{
QUrl old_url_cleaned = this->current_location;
QUrl new_url_cleaned = url;
old_url_cleaned.setFragment("");
new_url_cleaned.setFragment("");
// Only jump to (potential) fragment when we either change the base url
// or reload the current page with a new fragment.
this->navigate_to_fragment = ((old_url_cleaned != new_url_cleaned) or (this->current_location.fragment() != url.fragment()));
}
this->is_internal_location = (url.scheme() == "about" || url.scheme() == "file");
this->current_location = url;
this->setUrlBarText(urlstr);
this->network_timeout_timer.start(kristall::globals().options.network_timeout);
const auto req = [this, &url, &options]()
{
return this->current_handler->startRequest(url.adjusted(QUrl::RemoveFragment), options);
};
if ((flags & RequestFlags::DontReadFromCache) || this->current_identity.isValid())
{
return req();
}
// Check if we have the page in our cache.
kristall::globals().cache.clean();
if (auto pg = kristall::globals().cache.find(url); pg != nullptr)
{
qDebug() << "Reading page from cache";
this->was_read_from_cache = true;
this->on_requestComplete(pg->body, pg->mime);
// Move scrollbar to cached position, but only if url or the fragment has changed
if ((flags & RequestFlags::NavigatedBackOrForward) && pg->scroll_pos != -1) {
this->ui->text_browser->verticalScrollBar()->setValue(pg->scroll_pos);
}
return true;
}
else
{
return req();
}
}
void BrowserTab::updateMouseCursor(bool waiting)
{
if (waiting)
this->ui->text_browser->setDefaultCursor(Qt::BusyCursor);
else
this->ui->text_browser->setDefaultCursor(KristallTextBrowser::NORMAL_CURSOR);
}
bool BrowserTab::enableClientCertificate(const CryptoIdentity &ident)
{
if (not ident.isValid())
{
QMessageBox::warning(this, "Kristall", tr("Failed to generate temporary crypto-identitiy"));
this->disableClientCertificate();
return false;
}
this->current_identity = ident;
this->ui->enable_client_cert_button->setChecked(true);
return true;
}
void BrowserTab::disableClientCertificate()
{
for(auto & handler : this->protocol_handlers) {
handler->disableClientCertificate();
}
this->ui->enable_client_cert_button->setChecked(false);
this->current_identity = CryptoIdentity();
}
bool BrowserTab::searchBoxFind(QString text, bool backward)
{
// First we escape the query to be suitable to use inside a regex pattern.
// https://stackoverflow.com/a/3561711
static const QRegularExpression ESCAPE_REGEX = QRegularExpression(R"(([-\/\\^$*+?.()|[\]{}]))");
text.replace(ESCAPE_REGEX, "\\\\1");
// This part allows us to match different types of quotes easily:
// ' -> ('||)
// " -> ("|“|”)
static const QRegularExpression QUOTES_SINGLE_REGEX = QRegularExpression("'");
static const QRegularExpression QUOTES_DOUBLE_REGEX = QRegularExpression("\"");
text.replace(QUOTES_SINGLE_REGEX, "('||)").replace(QUOTES_DOUBLE_REGEX, "(\"|“|”)");
// Perform search using our new regex
return this->ui->text_browser->find(
#if (QT_VERSION >= QT_VERSION_CHECK(5, 13, 0))
QRegularExpression(text, QRegularExpression::CaseInsensitiveOption),
#else
QRegExp(text, Qt::CaseInsensitive),
#endif
backward ? QTextDocument::FindBackward : QTextDocument::FindFlags());
}
void BrowserTab::on_text_browser_customContextMenuRequested(const QPoint pos)
{
QMenu menu;
QString anchor = ui->text_browser->anchorAt(pos);
if (not anchor.isEmpty())
{
QUrl real_url{anchor};
if (real_url.isRelative())
real_url = this->current_location.resolved(real_url);
connect(menu.addAction(tr("Open in new tab")), &QAction::triggered, [this, real_url]() {
mainWindow->addNewTab(false, real_url);
});
// "open in default browser" for HTTP/S links
if (real_url.scheme().startsWith("http", Qt::CaseInsensitive)) {
connect(menu.addAction(tr("Open with external web browser")), &QAction::triggered, [this, real_url]() {
if (!QDesktopServices::openUrl(real_url))
{
QMessageBox::warning(this, "Kristall",
tr("Failed to start system URL handler for\r\n%1").arg(real_url.toString()));
}
});
}
connect(menu.addAction(tr("Follow link")), &QAction::triggered, [this, real_url]() {
this->navigateTo(real_url, PushImmediate);
});
connect(menu.addAction(tr("Copy link")), &QAction::triggered, [real_url]() {
qApp->clipboard()->setText(real_url.toString(QUrl::FullyEncoded));
});
menu.addSeparator();
}
if (!ui->text_browser->textCursor().hasSelection()) {
QAction * back = menu.addAction(QIcon::fromTheme("go-previous"), tr("Back"), [this]() {
this->on_back_button_clicked();
});
back->setEnabled(history.oneBackward(current_history_index).isValid());
QAction * forward = menu.addAction(QIcon::fromTheme("go-next"), tr("Forward"), [this]() {
this->on_forward_button_clicked();
});
forward->setEnabled(history.oneForward(current_history_index).isValid());
if (this->current_handler && this->current_handler->isInProgress()) {
menu.addAction(QIcon::fromTheme("process-stop"), tr("Stop"), [this]() {
this->on_stop_button_clicked();
});
} else {
menu.addAction(QIcon::fromTheme("view-refresh"), tr("Refresh"), [this]() {
this->on_refresh_button_clicked();
});
}
menu.addSeparator();
} else {
menu.addAction(tr("Copy to clipboard"), [this]() {
this->ui->text_browser->betterCopy();
}, QKeySequence("Ctrl+C"));
}
connect(menu.addAction(tr("Select all")), &QAction::triggered, [this]() {
this->ui->text_browser->selectAll();
});
menu.addSeparator();
QAction * viewsrc = menu.addAction(tr("View document source"));
viewsrc->setShortcut(QKeySequence("Ctrl+U"));
connect(viewsrc, &QAction::triggered, this, &BrowserTab::openSourceView);
menu.exec(ui->text_browser->mapToGlobal(pos));
}
void BrowserTab::on_enable_client_cert_button_clicked(bool checked)
{
if (checked)
{
trySetClientCertificate(QString{});
}
else
{
resetClientCertificate();
}
}
void BrowserTab::on_search_box_textChanged(const QString &arg1)
{
this->ui->text_browser->setTextCursor(QTextCursor { this->ui->text_browser->document() });
this->searchBoxFind(arg1);
}
void BrowserTab::on_search_box_returnPressed()
{
this->searchBoxFind(this->ui->search_box->text());
}
void BrowserTab::on_search_next_clicked()
{
if (!this->searchBoxFind(this->ui->search_box->text()) &&
this->current_buffer.contains(this->ui->search_box->text().toUtf8()))
{
// Wrap search
this->ui->text_browser->moveCursor(QTextCursor::Start);
this->searchBoxFind(this->ui->search_box->text());
}
}
void BrowserTab::on_search_previous_clicked()
{
if (!this->searchBoxFind(this->ui->search_box->text(), true) &&
this->current_buffer.contains(this->ui->search_box->text().toUtf8()))
{
// Wrap search
this->ui->text_browser->moveCursor(QTextCursor::End);
this->searchBoxFind(this->ui->search_box->text(), true);
}
}
void BrowserTab::on_close_search_clicked()
{
this->ui->search_bar->setVisible(false);
}
void BrowserTab::dragEnterEvent(QDragEnterEvent *event)
{
if (event->mimeData()->hasUrls())
event->acceptProposedAction();
}
void BrowserTab::dropEvent(QDropEvent *event)
{
for (const auto &url : event->mimeData()->urls())
mainWindow->addNewTab(true, url);
}
void BrowserTab::resizeEvent(QResizeEvent *event)
{
this->updatePageMargins();
QWidget::resizeEvent(event);
}