File indexing completed on 2024-05-05 05:51:20

0001 /*
0002     SPDX-FileCopyrightText: 2018 Sven Brauch <mail@svenbrauch.de>
0003     SPDX-FileCopyrightText: 2018 Michal Srb <michalsrb@gmail.com>
0004     SPDX-FileCopyrightText: 2020 Jan Paul Batrina <jpmbatrina01@gmail.com>
0005     SPDX-FileCopyrightText: 2021 Dominik Haumann <dhaumann@kde.org>
0006 
0007     SPDX-License-Identifier: LGPL-2.0-or-later
0008 */
0009 
0010 #include "katecolorpickerplugin.h"
0011 
0012 #include <KConfigGroup>
0013 #include <KLocalizedString>
0014 #include <KPluginFactory>
0015 #include <KSharedConfig>
0016 #include <KTextEditor/Document>
0017 #include <KTextEditor/View>
0018 
0019 #include <QColor>
0020 #include <QColorDialog>
0021 #include <QFontMetricsF>
0022 #include <QPainter>
0023 #include <QPointer>
0024 #include <QVariant>
0025 
0026 ColorPickerInlineNoteProvider::ColorPickerInlineNoteProvider(KTextEditor::Document *doc)
0027     : m_doc(doc)
0028 {
0029     // initialize the color regex
0030     m_colorRegex.setPatternOptions(QRegularExpression::DontCaptureOption | QRegularExpression::CaseInsensitiveOption);
0031     updateColorMatchingCriteria();
0032 
0033     const auto views = m_doc->views();
0034     for (auto view : views) {
0035         view->registerInlineNoteProvider(this);
0036     }
0037 
0038     connect(m_doc, &KTextEditor::Document::viewCreated, this, [this](KTextEditor::Document *, KTextEditor::View *view) {
0039         view->registerInlineNoteProvider(this);
0040     });
0041 
0042     auto lineChanged = [this](const int line) {
0043         if (m_startChangedLines == -1 || m_endChangedLines == -1) {
0044             m_startChangedLines = line;
0045             // changed line is directly above/below the previous changed line, so we just update them
0046         } else if (line == m_endChangedLines) { // handled below. Condition added here to avoid fallthrough
0047         } else if (line == m_startChangedLines - 1) {
0048             m_startChangedLines = line;
0049         } else if (line < m_startChangedLines || line > m_endChangedLines) {
0050             // changed line is outside the range of previous changes. Change proably skipped lines
0051             updateNotes(m_startChangedLines, m_endChangedLines);
0052             m_startChangedLines = line;
0053             m_endChangedLines = -1;
0054         }
0055 
0056         m_endChangedLines = line >= m_endChangedLines ? line + 1 : m_endChangedLines;
0057     };
0058 
0059     // textInserted and textRemoved are emitted per line, then the last line is followed by a textChanged signal
0060     connect(m_doc, &KTextEditor::Document::textInserted, this, [lineChanged](KTextEditor::Document *, const KTextEditor::Cursor &cur, const QString &) {
0061         lineChanged(cur.line());
0062     });
0063     connect(m_doc, &KTextEditor::Document::textRemoved, this, [lineChanged](KTextEditor::Document *, const KTextEditor::Range &range, const QString &) {
0064         lineChanged(range.start().line());
0065     });
0066     connect(m_doc, &KTextEditor::Document::textChanged, this, [this](KTextEditor::Document *) {
0067         int newNumLines = m_doc->lines();
0068         if (m_startChangedLines == -1) {
0069             // textChanged not preceded by textInserted or textRemoved. This probably means that either:
0070             //   *empty line(s) were inserted/removed (TODO: Update only the lines directly below the removed/inserted empty line(s))
0071             //   *the document is newly opened so we update all lines
0072             updateNotes();
0073         } else {
0074             if (m_previousNumLines != newNumLines) {
0075                 // either whole line(s) were removed or inserted. We update all lines (even those that are now non-existent) below m_startChangedLines
0076                 m_endChangedLines = newNumLines > m_previousNumLines ? newNumLines : m_previousNumLines;
0077             }
0078             updateNotes(m_startChangedLines, m_endChangedLines);
0079         }
0080 
0081         m_startChangedLines = -1;
0082         m_endChangedLines = -1;
0083         m_previousNumLines = newNumLines;
0084     });
0085 
0086     updateNotes();
0087 }
0088 
0089 ColorPickerInlineNoteProvider::~ColorPickerInlineNoteProvider()
0090 {
0091     QPointer<KTextEditor::Document> doc = m_doc;
0092     if (doc) {
0093         const auto views = m_doc->views();
0094         for (auto view : views) {
0095             view->unregisterInlineNoteProvider(this);
0096         }
0097     }
0098 }
0099 
0100 void ColorPickerInlineNoteProvider::updateColorMatchingCriteria()
0101 {
0102     KConfigGroup config(KSharedConfig::openConfig(), QStringLiteral("ColorPicker"));
0103     m_matchHexLengths = config.readEntry("HexLengths", QList<int>{12, 9, 6, 3}).toVector();
0104     m_putPreviewAfterColor = config.readEntry("PreviewAfterColor", true);
0105     m_matchNamedColors = config.readEntry("NamedColors", false);
0106 
0107     QString colorRegex;
0108     if (m_matchHexLengths.size() > 0) {
0109         colorRegex += QLatin1String("(#[[:xdigit:]]{3,12})");
0110     }
0111 
0112     if (m_matchNamedColors) {
0113         if (!colorRegex.isEmpty()) {
0114             colorRegex += QLatin1Char('|');
0115         }
0116         // shortest and longest colors have 3 (e.g. red) and 20 (lightgoldenrodyellow) characters respectively
0117         colorRegex += QLatin1String("((?<![\\w])[a-z]{3,20})");
0118     }
0119 
0120     if (!colorRegex.isEmpty()) {
0121         colorRegex = QStringLiteral("(?<![-])(%1)(?![-\\w])").arg(colorRegex);
0122     } else {
0123         // No matching criteria enabled. Set regex to negative lookahead to match nothing.
0124         colorRegex = QLatin1String("(?!)");
0125     }
0126 
0127     m_colorRegex.setPattern(colorRegex);
0128 }
0129 
0130 void ColorPickerInlineNoteProvider::updateNotes(int startLine, int endLine)
0131 {
0132     if (m_colorNoteIndices.isEmpty()) {
0133         return;
0134     }
0135 
0136     startLine = startLine < -1 ? -1 : startLine;
0137     if (startLine == -1) {
0138         startLine = 0;
0139         //  we use whichever of newNumLines and m_previousNumLines are longer so that note indices for non-existent lines are also removed
0140         const int lastLine = m_doc->lines();
0141         endLine = lastLine > m_previousNumLines ? lastLine : m_previousNumLines;
0142     }
0143 
0144     if (endLine == -1) {
0145         endLine = startLine;
0146     }
0147 
0148     for (int line = startLine; line < endLine; ++line) {
0149         int removed = m_colorNoteIndices.remove(line);
0150         if (removed != 0) {
0151             Q_EMIT inlineNotesChanged(line);
0152         }
0153     }
0154 }
0155 
0156 QList<int> ColorPickerInlineNoteProvider::inlineNotes(int line) const
0157 {
0158     if (!m_colorNoteIndices.contains(line)) {
0159         const QString lineText = m_doc->line(line);
0160         auto matchIter = m_colorRegex.globalMatch(lineText);
0161         while (matchIter.hasNext()) {
0162             const auto match = matchIter.next();
0163             if (!QColor(match.captured()).isValid()) {
0164                 continue;
0165             }
0166 
0167             if (lineText.at(match.capturedStart()) == QLatin1Char('#') && !m_matchHexLengths.contains(match.capturedLength() - 1)) {
0168                 // matching for this hex color format is disabled
0169                 continue;
0170             }
0171 
0172             int start = match.capturedStart();
0173             int end = start + match.capturedLength();
0174             if (m_putPreviewAfterColor) {
0175                 start = end;
0176                 end = match.capturedStart();
0177             }
0178 
0179             auto &colorIndices = m_colorNoteIndices[line];
0180             colorIndices.colorNoteIndices.append(start);
0181             colorIndices.otherColorIndices.append(end);
0182         }
0183     }
0184 
0185     return m_colorNoteIndices[line].colorNoteIndices;
0186 }
0187 
0188 QSize ColorPickerInlineNoteProvider::inlineNoteSize(const KTextEditor::InlineNote &note) const
0189 {
0190     return QSize(note.lineHeight() - 1, note.lineHeight() - 1);
0191 }
0192 
0193 void ColorPickerInlineNoteProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter, Qt::LayoutDirection) const
0194 {
0195     const auto line = note.position().line();
0196     auto colorEnd = note.position().column();
0197 
0198     const QList<int> &colorNoteIndices = m_colorNoteIndices[line].colorNoteIndices;
0199     // Since the colorNoteIndices are inserted in left-to-right (increasing) order in inlineNotes(), we can use binary search to find the index (or color note
0200     // number) for the line
0201     const int colorNoteNumber = std::lower_bound(colorNoteIndices.cbegin(), colorNoteIndices.cend(), colorEnd) - colorNoteIndices.cbegin();
0202     auto colorStart = m_colorNoteIndices[line].otherColorIndices[colorNoteNumber];
0203 
0204     if (colorStart > colorEnd) {
0205         colorEnd = colorStart;
0206         colorStart = note.position().column();
0207     }
0208 
0209     const auto color = QColor(m_doc->text({line, colorStart, line, colorEnd}));
0210     // ensure that the border color is always visible
0211     QColor penColor = color;
0212     penColor.setAlpha(255);
0213     painter.setPen(penColor.value() < 128 ? penColor.lighter(150) : penColor.darker(150));
0214 
0215     painter.setBrush(color);
0216     painter.setRenderHint(QPainter::Antialiasing, false);
0217     const QFontMetricsF fm(note.font());
0218     const int inc = note.underMouse() ? 1 : 0;
0219     const int ascent = fm.ascent();
0220     const int margin = (note.lineHeight() - ascent) / 2;
0221     painter.drawRect(margin - inc, margin - inc, ascent - 1 + 2 * inc, ascent - 1 + 2 * inc);
0222 }
0223 
0224 void ColorPickerInlineNoteProvider::inlineNoteActivated(const KTextEditor::InlineNote &note, Qt::MouseButtons, const QPoint &)
0225 {
0226     const auto line = note.position().line();
0227     auto colorEnd = note.position().column();
0228 
0229     const QList<int> &colorNoteIndices = m_colorNoteIndices[line].colorNoteIndices;
0230     // Since the colorNoteIndices are inserted in left-to-right (increasing) order in inlineNotes, we can use binary search to find the index (or color note
0231     // number) for the line
0232     const int colorNoteNumber = std::lower_bound(colorNoteIndices.cbegin(), colorNoteIndices.cend(), colorEnd) - colorNoteIndices.cbegin();
0233     auto colorStart = m_colorNoteIndices[line].otherColorIndices[colorNoteNumber];
0234     if (colorStart > colorEnd) {
0235         colorEnd = colorStart;
0236         colorStart = note.position().column();
0237     }
0238 
0239     const auto oldColor = QColor(m_doc->text({line, colorStart, line, colorEnd}));
0240     QColorDialog::ColorDialogOptions dialogOptions = QColorDialog::ShowAlphaChannel;
0241     QString title = i18n("Select Color (Hex output)");
0242     if (!m_doc->isReadWrite()) {
0243         dialogOptions |= QColorDialog::NoButtons;
0244         title = i18n("View Color [Read only]");
0245     }
0246     const QColor newColor = QColorDialog::getColor(oldColor, const_cast<KTextEditor::View *>(note.view()), title, dialogOptions);
0247     if (!newColor.isValid()) {
0248         return;
0249     }
0250 
0251     // include alpha channel if the new color has transparency or the old color included transparency (#AARRGGBB, 9 hex digits)
0252     auto colorNameFormat = (newColor.alpha() != 255 || colorEnd - colorStart == 9) ? QColor::HexArgb : QColor::HexRgb;
0253     m_doc->replaceText({line, colorStart, line, colorEnd}, newColor.name(colorNameFormat));
0254 }
0255 
0256 K_PLUGIN_FACTORY_WITH_JSON(KateColorPickerPluginFactory, "katecolorpickerplugin.json", registerPlugin<KateColorPickerPlugin>();)
0257 
0258 KateColorPickerPlugin::KateColorPickerPlugin(QObject *parent, const QVariantList &)
0259     : KTextEditor::Plugin(parent)
0260 {
0261 }
0262 
0263 KateColorPickerPlugin::~KateColorPickerPlugin() = default;
0264 
0265 QObject *KateColorPickerPlugin::createView(KTextEditor::MainWindow *mainWindow)
0266 {
0267     m_mainWindow = mainWindow;
0268     const auto views = m_mainWindow->views();
0269     for (auto view : views) {
0270         addDocument(view->document());
0271     }
0272 
0273     connect(m_mainWindow, &KTextEditor::MainWindow::viewCreated, this, [this](KTextEditor::View *view) {
0274         addDocument(view->document());
0275     });
0276 
0277     return nullptr;
0278 }
0279 
0280 void KateColorPickerPlugin::addDocument(KTextEditor::Document *doc)
0281 {
0282     if (m_inlineColorNoteProviders.find(doc) == m_inlineColorNoteProviders.end()) {
0283         m_inlineColorNoteProviders.emplace(doc, new ColorPickerInlineNoteProvider(doc));
0284     }
0285 
0286     connect(doc, &KTextEditor::Document::aboutToClose, this, [this, doc]() {
0287         m_inlineColorNoteProviders.erase(doc);
0288     });
0289 }
0290 
0291 void KateColorPickerPlugin::readConfig()
0292 {
0293     for (const auto &[doc, colorNoteProvider] : m_inlineColorNoteProviders) {
0294         Q_UNUSED(doc)
0295         colorNoteProvider->updateColorMatchingCriteria();
0296         colorNoteProvider->updateNotes();
0297     }
0298 }
0299 
0300 #include "katecolorpickerplugin.moc"
0301 #include "moc_katecolorpickerplugin.cpp"