File indexing completed on 2025-01-05 05:20:13
0001 /* 0002 SPDX-FileCopyrightText: 2023 Waqar Ahmed <waqar.17a@gmail.com> 0003 SPDX-License-Identifier: GPL-2.0-or-later 0004 */ 0005 #include "OpenLinkPlugin.h" 0006 0007 #include <KLocalizedString> 0008 #include <KPluginFactory> 0009 #include <KSharedConfig> 0010 #include <KTextEditor/Document> 0011 #include <KTextEditor/TextHintInterface> 0012 #include <KTextEditor/View> 0013 #include <KXMLGUIFactory> 0014 0015 #include <QDesktopServices> 0016 #include <QEvent> 0017 #include <QFileInfo> 0018 #include <QMouseEvent> 0019 #include <QToolTip> 0020 #include <QUrl> 0021 0022 class OpenLinkTextHint : public KTextEditor::TextHintProvider 0023 { 0024 OpenLinkPluginView *m_pview; 0025 QPointer<KTextEditor::View> m_view; 0026 0027 public: 0028 OpenLinkTextHint(OpenLinkPluginView *pview) 0029 : m_pview(pview) 0030 { 0031 } 0032 0033 ~OpenLinkTextHint() override 0034 { 0035 if (m_view) { 0036 m_view->unregisterTextHintProvider(this); 0037 } 0038 } 0039 0040 void setView(KTextEditor::View *v) 0041 { 0042 if (m_view) { 0043 m_view->unregisterTextHintProvider(this); 0044 } 0045 if (v) { 0046 m_view = v; 0047 m_view->registerTextHintProvider(this); 0048 } 0049 } 0050 0051 QString textHint(KTextEditor::View *view, const KTextEditor::Cursor &position) override 0052 { 0053 auto doc = view->document(); 0054 // If the cursor is inside the link area, show a hint to the user 0055 auto currentPos = view->cursorPosition(); 0056 auto it = m_pview->m_docHighligtedLinkRanges.find(doc); 0057 if (it != m_pview->m_docHighligtedLinkRanges.end()) { 0058 const auto &ranges = it->second; 0059 for (const auto &range : ranges) { 0060 if (range && range->contains(position) && range->contains(currentPos)) { 0061 const QString hint = QStringLiteral("<p>") + i18n("Ctrl+Click to open link") + QStringLiteral("</p>"); 0062 return hint; 0063 } 0064 } 0065 } 0066 return {}; 0067 } 0068 }; 0069 0070 // TODO: Support file urls 0071 // TODO: Allow the user to specify url matching re 0072 // so that we can click on stuff like BUG-123123 and go to bugs.blah.org/123123 0073 // re windows [a-zA-Z]:[\\\/](?:[a-zA-Z0-9]+[\\\/])*([a-zA-Z0-9]+) 0074 0075 K_PLUGIN_FACTORY_WITH_JSON(OpenLinkPluginFactory, "OpenLinkPlugin.json", registerPlugin<OpenLinkPlugin>();) 0076 0077 class GotoLinkHover : public QObject 0078 { 0079 Q_OBJECT 0080 public: 0081 void highlight(KTextEditor::View *activeView, KTextEditor::Range range) 0082 { 0083 if (!activeView || !activeView->document() || !viewInternal) { 0084 return; 0085 } 0086 0087 viewInternal->setCursor(Qt::PointingHandCursor); 0088 // underline the hovered word 0089 auto doc = activeView->document(); 0090 if (!m_movingRange || doc != m_movingRange->document()) { 0091 m_movingRange.reset(doc->newMovingRange(range)); 0092 // clang-format off 0093 connect(doc, &KTextEditor::Document::aboutToInvalidateMovingInterfaceContent, this, &GotoLinkHover::clearMovingRange, Qt::UniqueConnection); 0094 connect(doc, &KTextEditor::Document::aboutToDeleteMovingInterfaceContent, this, &GotoLinkHover::clearMovingRange, Qt::UniqueConnection); 0095 // clang-format on 0096 } else { 0097 m_movingRange->setRange(range); 0098 } 0099 0100 static const KTextEditor::Attribute::Ptr attr([] { 0101 auto attr = new KTextEditor::Attribute; 0102 // Bluish, works with light/dark bg 0103 attr->setForeground(QColor(0x409DFF)); 0104 return attr; 0105 }()); 0106 m_movingRange->setAttribute(attr); 0107 } 0108 0109 void clear() 0110 { 0111 if (m_movingRange) { 0112 m_movingRange->setRange(KTextEditor::Range::invalid()); 0113 } 0114 if (viewInternal && viewInternal->cursor() != Qt::IBeamCursor) { 0115 viewInternal->setCursor(Qt::IBeamCursor); 0116 } 0117 viewInternal.clear(); 0118 currentWord.clear(); 0119 } 0120 0121 QString currentWord; 0122 QPointer<QWidget> viewInternal; 0123 0124 private: 0125 Q_SLOT void clearMovingRange(KTextEditor::Document *doc) 0126 { 0127 if (m_movingRange && m_movingRange->document() == doc) { 0128 m_movingRange.reset(); 0129 } 0130 } 0131 0132 private: 0133 std::unique_ptr<KTextEditor::MovingRange> m_movingRange; 0134 }; 0135 0136 QObject *OpenLinkPlugin::createView(KTextEditor::MainWindow *mainWindow) 0137 { 0138 return new OpenLinkPluginView(this, mainWindow); 0139 } 0140 0141 OpenLinkPluginView::OpenLinkPluginView(OpenLinkPlugin *plugin, KTextEditor::MainWindow *mainWin) 0142 : QObject(plugin) 0143 , m_mainWindow(mainWin) 0144 , m_ctrlHoverFeedback(new GotoLinkHover()) 0145 , m_textHintProvider(new OpenLinkTextHint(this)) 0146 { 0147 connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &OpenLinkPluginView::onActiveViewChanged); 0148 onActiveViewChanged(m_mainWindow->activeView()); 0149 m_mainWindow->guiFactory()->addClient(this); 0150 } 0151 0152 OpenLinkPluginView::~OpenLinkPluginView() 0153 { 0154 m_textHintProvider->setView(nullptr); 0155 delete m_textHintProvider; 0156 disconnect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &OpenLinkPluginView::onActiveViewChanged); 0157 onActiveViewChanged(nullptr); 0158 m_mainWindow->guiFactory()->removeClient(this); 0159 } 0160 0161 void OpenLinkPluginView::onActiveViewChanged(KTextEditor::View *view) 0162 { 0163 auto oldView = m_activeView; 0164 if (oldView == view) { 0165 return; 0166 } 0167 m_activeView = view; 0168 m_textHintProvider->setView(view); 0169 0170 if (view && view->focusProxy()) { 0171 view->focusProxy()->installEventFilter(this); 0172 connect(view, &KTextEditor::View::verticalScrollPositionChanged, this, &OpenLinkPluginView::onViewScrolled); 0173 onViewScrolled(); 0174 0175 auto doc = view->document(); 0176 connect(doc, &KTextEditor::Document::textInserted, this, &OpenLinkPluginView::onTextInserted); 0177 connect(doc, &KTextEditor::Document::textRemoved, this, &OpenLinkPluginView::onTextRemoved); 0178 0179 connect(doc, &KTextEditor::Document::aboutToInvalidateMovingInterfaceContent, this, &OpenLinkPluginView::clear, Qt::UniqueConnection); 0180 connect(doc, &KTextEditor::Document::aboutToDeleteMovingInterfaceContent, this, &OpenLinkPluginView::clear, Qt::UniqueConnection); 0181 } 0182 0183 if (oldView && oldView->focusProxy()) { 0184 oldView->focusProxy()->removeEventFilter(this); 0185 disconnect(oldView, &KTextEditor::View::verticalScrollPositionChanged, this, &OpenLinkPluginView::onViewScrolled); 0186 disconnect(oldView->document(), &KTextEditor::Document::textInserted, this, &OpenLinkPluginView::onTextInserted); 0187 disconnect(oldView->document(), &KTextEditor::Document::textRemoved, this, &OpenLinkPluginView::onTextRemoved); 0188 } 0189 } 0190 0191 void OpenLinkPluginView::clear(KTextEditor::Document *doc) 0192 { 0193 m_docHighligtedLinkRanges.erase(doc); 0194 } 0195 0196 void OpenLinkPluginView::gotoLink() 0197 { 0198 const QUrl u = QUrl::fromUserInput(m_ctrlHoverFeedback->currentWord); 0199 if (u.isValid()) { 0200 QDesktopServices::openUrl(u); 0201 } 0202 } 0203 0204 static const QRegularExpression &linkRE() 0205 { 0206 static const QRegularExpression re( 0207 QStringLiteral(R"re((https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)))re")); 0208 return re; 0209 } 0210 0211 static void adjustMDLink(const QString &line, int capturedStart, int &capturedEnd) 0212 { 0213 if (capturedStart > 1) { // at least two chars before 0214 int i = capturedStart - 1; 0215 // for markdown [asd](google.com) style urls, make sure to strip last `)` 0216 bool isMD = line.at(i - 1) == QLatin1Char(']') && line.at(i) == QLatin1Char('('); 0217 if (isMD) { 0218 int f = line.lastIndexOf(QLatin1Char(')'), capturedEnd >= line.size() ? line.size() - 1 : capturedEnd); 0219 capturedEnd = f != -1 ? f : capturedEnd; 0220 } 0221 } 0222 } 0223 0224 void OpenLinkPluginView::highlightIfLink(KTextEditor::Cursor c, QWidget *viewInternal) 0225 { 0226 if (!m_activeView || !m_activeView->document() || !c.isValid()) { 0227 return; 0228 } 0229 0230 const auto doc = m_activeView->document(); 0231 const QString line = doc->line(c.line()); 0232 if (c.column() >= line.size()) { 0233 return; 0234 } 0235 0236 auto match = linkRE().match(line); 0237 const int capturedStart = match.capturedStart(); 0238 int capturedEnd = match.capturedEnd(); 0239 0240 if (match.hasMatch() && capturedStart <= c.column() && c.column() <= capturedEnd) { 0241 adjustMDLink(line, capturedStart, capturedEnd); 0242 m_ctrlHoverFeedback->currentWord = line.mid(capturedStart, capturedEnd - capturedStart); 0243 m_ctrlHoverFeedback->viewInternal = viewInternal; 0244 KTextEditor::Range range(c.line(), capturedStart, c.line(), capturedEnd); 0245 m_ctrlHoverFeedback->highlight(m_activeView, range); 0246 } 0247 } 0248 0249 void OpenLinkPluginView::onTextInserted(KTextEditor::Document *doc, KTextEditor::Cursor pos, const QString &text) 0250 { 0251 if (doc == m_activeView->document()) { 0252 KTextEditor::Range range(pos, pos); 0253 int newlines = text.count(QLatin1Char('\n')); 0254 pos.setLine(pos.line() + newlines); 0255 range.setEnd(pos); 0256 highlightLinks(range); 0257 } 0258 } 0259 0260 void OpenLinkPluginView::onTextRemoved(KTextEditor::Document *doc, KTextEditor::Range range, const QString &) 0261 { 0262 if (doc == m_activeView->document()) { 0263 highlightLinks(range); 0264 } 0265 } 0266 0267 void OpenLinkPluginView::onViewScrolled() 0268 { 0269 highlightLinks(KTextEditor::Range::invalid()); 0270 } 0271 0272 void OpenLinkPluginView::highlightLinks(KTextEditor::Range range) 0273 { 0274 if (!m_activeView) { 0275 return; 0276 } 0277 const auto lineRange = range.toLineRange(); 0278 0279 const int startLine = lineRange.isValid() ? lineRange.start() : m_activeView->firstDisplayedLine(); 0280 const int endLine = lineRange.isValid() ? lineRange.end() : m_activeView->lastDisplayedLine(); 0281 auto doc = m_activeView->document(); 0282 auto &ranges = m_docHighligtedLinkRanges[doc]; 0283 if (lineRange.isValid()) { 0284 ranges.erase(std::remove_if(ranges.begin(), 0285 ranges.end(), 0286 [lineRange](const std::unique_ptr<KTextEditor::MovingRange> &r) { 0287 return lineRange.overlapsLine(r->start().line()); 0288 }), 0289 ranges.end()); 0290 } else { 0291 ranges.clear(); 0292 } 0293 // Loop over visible lines and highlight links 0294 for (int i = startLine; i <= endLine; ++i) { 0295 const QString line = doc->line(i); 0296 QRegularExpressionMatchIterator it = linkRE().globalMatch(line); 0297 while (it.hasNext()) { 0298 auto match = it.next(); 0299 if (match.hasMatch()) { 0300 int capturedEnd = match.capturedEnd(); 0301 adjustMDLink(line, match.capturedStart(), capturedEnd); 0302 KTextEditor::Range range(i, match.capturedStart(), i, capturedEnd); 0303 KTextEditor::MovingRange *r = doc->newMovingRange(range); 0304 static const KTextEditor::Attribute::Ptr attr([] { 0305 auto attr = new KTextEditor::Attribute; 0306 attr->setUnderlineStyle(QTextCharFormat::SingleUnderline); 0307 return attr; 0308 }()); 0309 r->setAttribute(attr); 0310 ranges.emplace_back(r); 0311 // qDebug() << match.captured() << match.capturedStart() << match.capturedEnd(); 0312 } 0313 } 0314 } 0315 } 0316 0317 bool OpenLinkPluginView::eventFilter(QObject *obj, QEvent *event) 0318 { 0319 if (event->type() == QEvent::Leave) { 0320 m_ctrlHoverFeedback->clear(); 0321 return QObject::eventFilter(obj, event); 0322 } 0323 0324 if (event->type() != QEvent::MouseMove && event->type() != QEvent::MouseButtonRelease) { 0325 return QObject::eventFilter(obj, event); 0326 } 0327 0328 auto mouseEvent = static_cast<QMouseEvent *>(event); 0329 0330 // The user pressed Ctrl + Click 0331 if (event->type() == QEvent::MouseButtonRelease && !m_ctrlHoverFeedback->currentWord.isEmpty()) { 0332 if (mouseEvent->button() == Qt::LeftButton && mouseEvent->modifiers() == Qt::ControlModifier) { 0333 gotoLink(); 0334 m_ctrlHoverFeedback->clear(); 0335 return true; 0336 } 0337 } 0338 // The user is hovering with Ctrl pressed 0339 else if (event->type() == QEvent::MouseMove) { 0340 if (mouseEvent->modifiers() == Qt::ControlModifier) { 0341 auto viewInternal = static_cast<QWidget *>(obj); 0342 const QPoint coords = viewInternal->mapTo(m_activeView, mouseEvent->pos()); 0343 const KTextEditor::Cursor cur = m_activeView->coordinatesToCursor(coords); 0344 if (!cur.isValid() || m_activeView->document()->wordRangeAt(cur).isEmpty()) { 0345 return false; 0346 } 0347 highlightIfLink(cur, viewInternal); 0348 } else { 0349 // if there is no word or simple mouse move, make sure to unset the cursor and remove the highlight 0350 if (m_ctrlHoverFeedback->viewInternal) { 0351 m_ctrlHoverFeedback->clear(); 0352 } 0353 } 0354 } 0355 return false; 0356 } 0357 0358 #include "OpenLinkPlugin.moc" 0359 #include "moc_OpenLinkPlugin.cpp"