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"