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"