File indexing completed on 2024-05-05 04:37:32

0001 /*
0002     SPDX-FileCopyrightText: 2010 Aleix Pol Gonzalez <aleixpol@kde.org>
0003     SPDX-FileCopyrightText: 2016 Igor Kushnir <igorkuo@gmail.com>
0004 
0005     SPDX-License-Identifier: LGPL-2.0-or-later
0006 */
0007 
0008 #include "standarddocumentationview.h"
0009 #include "documentationfindwidget.h"
0010 #include "debug.h"
0011 
0012 #include <util/zoomcontroller.h>
0013 
0014 #include <KConfigGroup>
0015 #include <KSharedConfig>
0016 
0017 #include <QVBoxLayout>
0018 #include <QContextMenuEvent>
0019 #include <QMouseEvent>
0020 #include <QMenu>
0021 #include <QUrl>
0022 
0023 #ifdef USE_QTWEBKIT
0024 #include <QFontDatabase>
0025 #include <QWebView>
0026 #include <QWebFrame>
0027 #include <QWebSettings>
0028 #else
0029 #include <util/kdevstringhandler.h>
0030 #include <QFile>
0031 #include <QNetworkRequest>
0032 #include <QNetworkReply>
0033 #include <QPointer>
0034 #include <QWebEngineView>
0035 #include <QWebEnginePage>
0036 #include <QWebEngineSettings>
0037 #include <QWebEngineUrlScheme>
0038 #include <QWebEngineUrlSchemeHandler>
0039 #include <QWebEngineUrlRequestJob>
0040 #include <QWebEngineProfile>
0041 #include <QWebEngineScript>
0042 #include <QWebEngineScriptCollection>
0043 #endif
0044 
0045 using namespace KDevelop;
0046 
0047 #ifndef USE_QTWEBKIT
0048 namespace {
0049 auto qtHelpSchemeName() { return QByteArrayLiteral("qthelp"); }
0050 
0051 class StandardDocumentationPage : public QWebEnginePage
0052 {
0053     Q_OBJECT
0054 public:
0055     StandardDocumentationPage(QWebEngineProfile* profile, KDevelop::StandardDocumentationView* parent)
0056         : QWebEnginePage(profile, parent),
0057           m_view(parent)
0058     {
0059     }
0060 
0061     bool acceptNavigationRequest(const QUrl &url, NavigationType type, bool isMainFrame) override
0062     {
0063         if (DOCUMENTATION().isDebugEnabled()) {
0064             if (url.scheme() == QLatin1String("data")) {
0065                 qCDebug(DOCUMENTATION) << "navigating to a manually constructed page because" << type;
0066             } else {
0067                 qCDebug(DOCUMENTATION) << "navigating to" << url << "because" << type;
0068             }
0069         }
0070 
0071         if (type == NavigationTypeLinkClicked && m_isDelegating) {
0072             emit m_view->linkClicked(url);
0073             return false;
0074         }
0075 
0076         return QWebEnginePage::acceptNavigationRequest(url, type, isMainFrame);
0077     }
0078 
0079     void setLinkDelegating(bool isDelegating) { m_isDelegating = isDelegating; }
0080 
0081 private:
0082     KDevelop::StandardDocumentationView* const m_view;
0083     bool m_isDelegating = false;
0084 };
0085 
0086 } // unnamed namespace
0087 #endif
0088 
0089 void StandardDocumentationView::registerCustomUrlSchemes()
0090 {
0091 #ifndef USE_QTWEBKIT
0092     QWebEngineUrlScheme scheme(qtHelpSchemeName());
0093     QWebEngineUrlScheme::registerScheme(scheme);
0094 #endif
0095 }
0096 
0097 class KDevelop::StandardDocumentationViewPrivate
0098 {
0099 public:
0100     ZoomController* m_zoomController = nullptr;
0101     IDocumentation::Ptr m_doc;
0102 
0103 #ifdef USE_QTWEBKIT
0104     QWebView *m_view = nullptr;
0105     void init(StandardDocumentationView* parent)
0106     {
0107         m_view = new QWebView(parent);
0108         QObject::connect(m_view, &QWebView::linkClicked, parent, &StandardDocumentationView::linkClicked);
0109 #else
0110     QWebEngineView* m_view = nullptr;
0111     StandardDocumentationPage* m_page = nullptr;
0112 
0113     ~StandardDocumentationViewPrivate()
0114     {
0115         // make sure the page is deleted before the profile
0116         // see https://doc.qt.io/qt-5/qwebenginepage.html#QWebEnginePage-1
0117         delete m_page;
0118     }
0119 
0120     void init(StandardDocumentationView* parent)
0121     {
0122         // prevent QWebEngine (Chromium) from overriding the signal handlers of KCrash
0123         const auto chromiumFlags = qgetenv("QTWEBENGINE_CHROMIUM_FLAGS");
0124         if (!chromiumFlags.contains("disable-in-process-stack-traces")) {
0125             qputenv("QTWEBENGINE_CHROMIUM_FLAGS", chromiumFlags + " --disable-in-process-stack-traces");
0126         }
0127         // not using the shared default profile here:
0128         // prevents conflicts with qthelp scheme handler being registered onto that single default profile
0129         // due to async deletion of old pages and their CustomSchemeHandler instance
0130         auto* profile = new QWebEngineProfile(parent);
0131         m_page = new StandardDocumentationPage(profile, parent);
0132         m_view = new QWebEngineView(parent);
0133         m_view->setPage(m_page);
0134 #endif
0135         m_view->setContextMenuPolicy(Qt::NoContextMenu);
0136 
0137         // The event filter is necessary for handling mouse events since they are swallowed by
0138         // QWebView and QWebEngineView.
0139         m_view->installEventFilter(parent);
0140     }
0141 };
0142 
0143 StandardDocumentationView::StandardDocumentationView(DocumentationFindWidget* findWidget, QWidget* parent)
0144     : QWidget(parent)
0145     , d_ptr(new StandardDocumentationViewPrivate)
0146 {
0147     Q_D(StandardDocumentationView);
0148 
0149     auto mainLayout = new QVBoxLayout(this);
0150     mainLayout->setContentsMargins(0, 0, 0, 0);
0151     setLayout(mainLayout);
0152 
0153     d->init(this);
0154     layout()->addWidget(d->m_view);
0155 
0156     findWidget->setEnabled(true);
0157     connect(findWidget, &DocumentationFindWidget::searchRequested, this, &StandardDocumentationView::search);
0158     connect(findWidget, &DocumentationFindWidget::searchDataChanged, this, &StandardDocumentationView::searchIncremental);
0159     connect(findWidget, &DocumentationFindWidget::searchFinished, this, &StandardDocumentationView::finishSearch);
0160 
0161 #ifdef USE_QTWEBKIT
0162     QFont sansSerifFont = QFontDatabase::systemFont(QFontDatabase::GeneralFont);
0163     QFont monospaceFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
0164 
0165     QWebSettings* s = d->m_view->settings();
0166 
0167     s->setFontFamily(QWebSettings::StandardFont, sansSerifFont.family());
0168     s->setFontFamily(QWebSettings::SerifFont, QStringLiteral("Serif"));
0169     s->setFontFamily(QWebSettings::SansSerifFont, sansSerifFont.family());
0170     s->setFontFamily(QWebSettings::FixedFont, monospaceFont.family());
0171 
0172     s->setFontSize(QWebSettings::DefaultFontSize, QFontInfo(sansSerifFont).pixelSize());
0173     s->setFontSize(QWebSettings::DefaultFixedFontSize, QFontInfo(monospaceFont).pixelSize());
0174 
0175     // Fixes for correct positioning. The problem looks like the following:
0176     //
0177     // 1) Some page is loaded and loadFinished() signal is emitted,
0178     //    after this QWebView set right position inside page.
0179     //
0180     // 2) After loadFinished() emitting, page JS code finishes it's work and changes
0181     //    font settings (size). This leads to page contents "moving" inside view widget
0182     //    and as a result we have wrong position.
0183     //
0184     // Such behavior occurs for example with QtHelp pages.
0185     //
0186     // To fix the problem, first, we disable view painter updates during load to avoid content
0187     // "flickering" and also to hide font size "jumping". Secondly, we reset position inside page
0188     // after loading with using standard QWebFrame method scrollToAnchor().
0189 
0190     connect(d->m_view, &QWebView::loadStarted, d->m_view, [this]() {
0191         Q_D(StandardDocumentationView);
0192         d->m_view->setUpdatesEnabled(false);
0193     });
0194 
0195     connect(d->m_view, &QWebView::loadFinished, this, [this](bool) {
0196         Q_D(StandardDocumentationView);
0197         if (d->m_view->url().isValid()) {
0198             d->m_view->page()->mainFrame()->scrollToAnchor(d->m_view->url().fragment());
0199         }
0200         d->m_view->setUpdatesEnabled(true);
0201     });
0202 #endif
0203 }
0204 
0205 KDevelop::StandardDocumentationView::~StandardDocumentationView()
0206 {
0207     Q_D(StandardDocumentationView);
0208 
0209     // Prevent getting a loadFinished() signal on destruction.
0210     disconnect(d->m_view, nullptr, this, nullptr);
0211 }
0212 
0213 void StandardDocumentationView::search ( const QString& text, DocumentationFindWidget::FindOptions options )
0214 {
0215     Q_D(StandardDocumentationView);
0216 
0217 #ifdef USE_QTWEBKIT
0218     using WebkitThing = QWebPage;
0219 #else
0220     using WebkitThing = QWebEnginePage;
0221 #endif
0222     WebkitThing::FindFlags ff = {};
0223     if(options & DocumentationFindWidget::Previous)
0224         ff |= WebkitThing::FindBackward;
0225 
0226     if(options & DocumentationFindWidget::MatchCase)
0227         ff |= WebkitThing::FindCaseSensitively;
0228 
0229     d->m_view->page()->findText(text, ff);
0230 }
0231 
0232 void StandardDocumentationView::searchIncremental(const QString& text, DocumentationFindWidget::FindOptions options)
0233 {
0234     Q_D(StandardDocumentationView);
0235 
0236 #ifdef USE_QTWEBKIT
0237     using WebkitThing = QWebPage;
0238 #else
0239     using WebkitThing = QWebEnginePage;
0240 #endif
0241     WebkitThing::FindFlags findFlags;
0242 
0243     if (options & DocumentationFindWidget::MatchCase)
0244         findFlags |= WebkitThing::FindCaseSensitively;
0245 
0246     // calling with changed text with added or removed chars at end will result in current
0247     // selection kept, if also matching new text
0248     // behaviour on changed case sensitivity though is advancing to next match even if current
0249     // would be still matching. as there is no control about currently shown match, nothing
0250     // we can do about it. thankfully case sensitivity does not happen too often, so should
0251     // not be too grave UX
0252     // at least with webengine 5.9.1 there is a bug when switching from no-casesensitivy to
0253     // casesensitivity, that global matches are not updated and the ones with non-matching casing
0254     // still active. no workaround so far.
0255     d->m_view->page()->findText(text, findFlags);
0256 }
0257 
0258 void StandardDocumentationView::finishSearch()
0259 {
0260     Q_D(StandardDocumentationView);
0261 
0262     // passing empty string to reset search, as told in API docs
0263     d->m_view->page()->findText(QString());
0264 }
0265 
0266 void StandardDocumentationView::initZoom(const QString& configSubGroup)
0267 {
0268     Q_D(StandardDocumentationView);
0269 
0270     Q_ASSERT_X(!d->m_zoomController, "StandardDocumentationView::initZoom", "Can not initZoom a second time.");
0271 
0272     const KConfigGroup outerGroup(KSharedConfig::openConfig(), QStringLiteral("Documentation View"));
0273     const KConfigGroup configGroup(&outerGroup, configSubGroup);
0274     d->m_zoomController = new ZoomController(configGroup, this);
0275     connect(d->m_zoomController, &ZoomController::factorChanged,
0276             this, &StandardDocumentationView::updateZoomFactor);
0277     updateZoomFactor(d->m_zoomController->factor());
0278 }
0279 
0280 void StandardDocumentationView::setDocumentation(const IDocumentation::Ptr& doc)
0281 {
0282     Q_D(StandardDocumentationView);
0283 
0284     if(d->m_doc)
0285         disconnect(d->m_doc.data());
0286     d->m_doc = doc;
0287     update();
0288     if(d->m_doc)
0289         connect(d->m_doc.data(), &IDocumentation::descriptionChanged, this, &StandardDocumentationView::update);
0290 }
0291 
0292 void StandardDocumentationView::update()
0293 {
0294     Q_D(StandardDocumentationView);
0295 
0296     if(d->m_doc) {
0297         setHtml(d->m_doc->description());
0298     } else
0299         qCDebug(DOCUMENTATION) << "calling StandardDocumentationView::update() on an uninitialized view";
0300 }
0301 
0302 
0303 void KDevelop::StandardDocumentationView::setOverrideCssFile(const QString& cssFilePath)
0304 {
0305 #ifdef USE_QTWEBKIT
0306     Q_D(StandardDocumentationView);
0307 
0308     d->m_view->settings()->setUserStyleSheetUrl(QUrl::fromLocalFile(cssFilePath));
0309 #else
0310     QFile file(cssFilePath);
0311     if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
0312         qCWarning(DOCUMENTATION) << "cannot read CSS file" << cssFilePath << ':' << file.error() << file.errorString();
0313         return;
0314     }
0315     const auto cssCode = file.readAll();
0316     setOverrideCssCode(cssCode);
0317 #endif
0318 }
0319 
0320 void StandardDocumentationView::setOverrideCssCode(const QByteArray& cssCode)
0321 {
0322     Q_D(StandardDocumentationView);
0323 
0324 #ifdef USE_QTWEBKIT
0325     // Experiments show that Base64UrlEncoding or Base64UrlEncoding|OmitTrailingEquals flags
0326     // must not be passed to the QByteArray::toBase64() call here: when the difference between
0327     // these encoding variants matters and the flag(s) are passed, the CSS code is not applied.
0328     const QByteArray dataUrl = "data:text/css;charset=utf-8;base64," + cssCode.toBase64();
0329     d->m_view->settings()->setUserStyleSheetUrl(QUrl::fromEncoded(dataUrl));
0330 #else
0331     const auto scriptName = QStringLiteral("OverrideCss");
0332     auto& scripts = d->m_view->page()->scripts();
0333 
0334     const auto oldScript = scripts.findScript(scriptName);
0335     scripts.remove(oldScript);
0336 
0337     if (cssCode.isEmpty()) {
0338         return;
0339     }
0340 
0341     // The loading of CSS via JavaScript has a downside: pages are first loaded as is, then
0342     // reloaded with the style applied. When a page is large, the reloading is conspicuous
0343     // or causes flickering. For example, this can be seen on cmake-modules man page.
0344     // This cannot be fixed by specifying an earlier injection point - DocumentCreation -
0345     // because, according to QWebEngineScript documentation, this is not suitable for any
0346     // DOM operation. So with the DocumentCreation injection point the CSS style is not
0347     // applied and the following error appears in KDevelop's output:
0348     // js: Uncaught TypeError: Cannot read property 'appendChild' of null
0349     QWebEngineScript script;
0350     script.setInjectionPoint(QWebEngineScript::DocumentReady);
0351     script.setName(scriptName);
0352     script.setRunsOnSubFrames(false);
0353     script.setSourceCode(QLatin1String("const css = document.createElement('style');"
0354                                        "css.innerText = '%1';"
0355                                        "document.head.appendChild(css);")
0356                              .arg(QString::fromUtf8(escapeJavaScriptString(cssCode))));
0357     script.setWorldId(QWebEngineScript::ApplicationWorld);
0358 
0359     scripts.insert(script);
0360 #endif
0361 }
0362 
0363 void KDevelop::StandardDocumentationView::load(const QUrl& url)
0364 {
0365     Q_D(StandardDocumentationView);
0366 
0367 #ifdef USE_QTWEBKIT
0368     d->m_view->load(url);
0369 #else
0370     d->m_view->page()->load(url);
0371 #endif
0372 }
0373 
0374 void KDevelop::StandardDocumentationView::setHtml(const QString& html)
0375 {
0376     Q_D(StandardDocumentationView);
0377 
0378 #ifdef USE_QTWEBKIT
0379     d->m_view->setHtml(html);
0380 #else
0381     d->m_view->page()->setHtml(html);
0382 #endif
0383 }
0384 
0385 #ifndef USE_QTWEBKIT
0386 class CustomSchemeHandler : public QWebEngineUrlSchemeHandler
0387 {
0388     Q_OBJECT
0389 public:
0390     explicit CustomSchemeHandler(QNetworkAccessManager* nam, QObject *parent = nullptr)
0391         : QWebEngineUrlSchemeHandler(parent), m_nam(nam) {}
0392 
0393     void requestStarted(QWebEngineUrlRequestJob *job) override {
0394         const QUrl url = job->requestUrl();
0395 
0396         auto* const reply = m_nam->get(QNetworkRequest(url));
0397 
0398         // Deliberately don't use job as context in this connection: if job is destroyed
0399         // before reply is finished, reply would be leaked. Using reply as context does
0400         // not impact behavior, but silences Clazy checker connect-3arg-lambda (level1).
0401         connect(reply, &QNetworkReply::finished, reply, [reply, job = QPointer{job}] {
0402             // At this point reply is no longer written to and can be safely
0403             // destroyed once job ends reading from it.
0404             if (job) {
0405                 connect(job, &QObject::destroyed, reply, &QObject::deleteLater);
0406             } else {
0407                 reply->deleteLater();
0408             }
0409         });
0410 
0411         job->reply(reply->header(QNetworkRequest::ContentTypeHeader).toByteArray(), reply);
0412     }
0413 
0414 private:
0415     QNetworkAccessManager* m_nam;
0416 };
0417 #endif
0418 
0419 void KDevelop::StandardDocumentationView::setNetworkAccessManager(QNetworkAccessManager* manager)
0420 {
0421     Q_D(StandardDocumentationView);
0422 
0423 #ifdef USE_QTWEBKIT
0424     d->m_view->page()->setNetworkAccessManager(manager);
0425 #else
0426     d->m_view->page()->profile()->installUrlSchemeHandler(qtHelpSchemeName(), new CustomSchemeHandler(manager, this));
0427 #endif
0428 }
0429 
0430 void KDevelop::StandardDocumentationView::setDelegateLinks(bool delegate)
0431 {
0432     Q_D(StandardDocumentationView);
0433 
0434 #ifdef USE_QTWEBKIT
0435     d->m_view->page()->setLinkDelegationPolicy(delegate ? QWebPage::DelegateAllLinks : QWebPage::DontDelegateLinks);
0436 #else
0437     d->m_page->setLinkDelegating(delegate);
0438 #endif
0439 }
0440 
0441 QMenu* StandardDocumentationView::createStandardContextMenu()
0442 {
0443     Q_D(StandardDocumentationView);
0444 
0445     auto menu = new QMenu(this);
0446 #ifdef USE_QTWEBKIT
0447     using WebkitThing = QWebPage;
0448 #else
0449     using WebkitThing = QWebEnginePage;
0450 #endif
0451     auto copyAction = d->m_view->pageAction(WebkitThing::Copy);
0452     if (copyAction) {
0453         copyAction->setIcon(QIcon::fromTheme(QStringLiteral("edit-copy")));
0454         menu->addAction(copyAction);
0455     }
0456     return menu;
0457 }
0458 
0459 bool StandardDocumentationView::eventFilter(QObject* object, QEvent* event)
0460 {
0461     Q_D(StandardDocumentationView);
0462 #ifndef USE_QTWEBKIT
0463     if (object == d->m_view) {
0464         /* HACK / Workaround for QTBUG-43602
0465          * Need to set an eventFilter on the child of WebengineView because it swallows
0466          * mouse events.
0467          */
0468         if (event->type() == QEvent::ChildAdded) {
0469             QObject* child = static_cast<QChildEvent*>(event)->child();
0470             if(qobject_cast<QWidget*>(child)) {
0471                 child->installEventFilter(this);
0472             }
0473         } else if (event->type() == QEvent::ChildRemoved) {
0474             QObject* child = static_cast<QChildEvent*>(event)->child();
0475             if(qobject_cast<QWidget*>(child)) {
0476                 child->removeEventFilter(this);
0477             }
0478         }
0479     }
0480 #endif
0481     if (event->type() == QEvent::Wheel) {
0482         auto* const wheelEvent = static_cast<QWheelEvent*>(event);
0483         if (d->m_zoomController && d->m_zoomController->handleWheelEvent(wheelEvent))
0484             return true;
0485     } else if (event->type() == QEvent::MouseButtonPress) {
0486         const auto button = static_cast<QMouseEvent*>(event)->button();
0487         switch (button) {
0488         case Qt::MouseButton::ForwardButton:
0489             emit browseForward();
0490             event->accept();
0491             return true;
0492         case Qt::MouseButton::BackButton:
0493             emit browseBack();
0494             event->accept();
0495             return true;
0496         default:
0497             break;
0498         }
0499     }
0500     return QWidget::eventFilter(object, event);
0501 }
0502 
0503 void StandardDocumentationView::contextMenuEvent(QContextMenuEvent* event)
0504 {
0505     auto menu = createStandardContextMenu();
0506     if (menu->isEmpty()) {
0507         delete menu;
0508         return;
0509     }
0510 
0511     menu->setAttribute(Qt::WA_DeleteOnClose);
0512     menu->exec(event->globalPos());
0513 }
0514 
0515 void StandardDocumentationView::updateZoomFactor(double zoomFactor)
0516 {
0517     Q_D(StandardDocumentationView);
0518 
0519     d->m_view->setZoomFactor(zoomFactor);
0520 }
0521 
0522 void StandardDocumentationView::keyReleaseEvent(QKeyEvent* event)
0523 {
0524     // Handle keyReleaseEvent instead of the usual keyPressEvent as a workaround
0525     // for the conflicting reset font size Ctrl+0 shortcut added into KTextEditor
0526     // in version 5.60. This new global shortcut prevents the Qt::Key_0 part of the
0527     // shortcut from reaching KeyPress events, but it doesn't affect KeyRelease events.
0528     // The end result is that Ctrl+0 always resets font size in the text editor
0529     // because its shortcut is global. In addition, Ctrl+0 resets zoom factor in
0530     // the current documentation provider if Documentation tool view has focus.
0531     // Unfortunately there is no way to reset documentation zoom factor without
0532     // simultaneously resetting font size in the text editor.
0533     // An alternative workaround - creating one more Ctrl+0 shortcut -
0534     // inevitably leads to conflicts with the KTextEditor's global shortcut.
0535     Q_D(StandardDocumentationView);
0536 
0537     if (d->m_zoomController && d->m_zoomController->handleKeyPressEvent(event)) {
0538         return;
0539     }
0540     QWidget::keyReleaseEvent(event);
0541 }
0542 
0543 #ifndef USE_QTWEBKIT
0544 #include "standarddocumentationview.moc"
0545 #endif
0546 
0547 #include "moc_standarddocumentationview.cpp"