noo/client/qmarkdowntextedit/qmarkdowntextedit.cpp

1882 lines
66 KiB
C++

/*
* MIT License
*
* Copyright (c) 2014-2023 Patrizio Bekerle -- <patrizio@bekerle.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#include "qmarkdowntextedit.h"
#include <QClipboard>
#include <QDebug>
#include <QDesktopServices>
#include <QDir>
#include <QGuiApplication>
#include <QKeyEvent>
#include <QLayout>
#include <QPainter>
#include <QPainterPath>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include <QScrollBar>
#include <QSettings>
#include <QTextBlock>
#include <QTimer>
#include <QWheelEvent>
#include <utility>
#include "linenumberarea.h"
#include "markdownhighlighter.h"
static const QByteArray _openingCharacters = QByteArrayLiteral("([{<*\"'_~");
static const QByteArray _closingCharacters = QByteArrayLiteral(")]}>*\"'_~");
QMarkdownTextEdit::QMarkdownTextEdit(QWidget *parent, bool initHighlighter)
: QPlainTextEdit(parent) {
installEventFilter(this);
viewport()->installEventFilter(this);
_autoTextOptions = AutoTextOption::BracketClosing;
_lineNumArea = new LineNumArea(this);
updateLineNumberAreaWidth(0);
// Markdown highlighting is enabled by default
_highlightingEnabled = initHighlighter;
if (initHighlighter) {
_highlighter = new MarkdownHighlighter(document());
}
QFont font = this->font();
// set the tab stop to the width of 4 spaces in the editor
constexpr int tabStop = 4;
QFontMetrics metrics(font);
#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0)
setTabStopWidth(tabStop * metrics.width(' '));
#else
setTabStopDistance(tabStop * metrics.horizontalAdvance(QLatin1Char(' ')));
#endif
// add shortcuts for duplicating text
// new QShortcut( QKeySequence( "Ctrl+D" ), this, SLOT( duplicateText() )
// ); new QShortcut( QKeySequence( "Ctrl+Alt+Down" ), this, SLOT(
// duplicateText() ) );
// add a layout to the widget
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addStretch();
this->setLayout(layout);
// add the hidden search widget
_searchWidget = new QPlainTextEditSearchWidget(this);
this->layout()->addWidget(_searchWidget);
connect(this, &QPlainTextEdit::textChanged, this,
&QMarkdownTextEdit::adjustRightMargin);
connect(this, &QPlainTextEdit::cursorPositionChanged, this,
&QMarkdownTextEdit::centerTheCursor);
connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int) {
_lineNumArea->update();
});
connect(this, &QPlainTextEdit::cursorPositionChanged, this, [this]() {
_lineNumArea->update();
auto oldArea = blockBoundingGeometry(_textCursor.block()).translated(contentOffset());
_textCursor = textCursor();
auto newArea = blockBoundingGeometry(_textCursor.block()).translated(contentOffset());
auto areaToUpdate = oldArea | newArea;
viewport()->update(areaToUpdate.toRect());
});
connect(document(), &QTextDocument::blockCountChanged,
this, &QMarkdownTextEdit::updateLineNumberAreaWidth);
connect(this, &QPlainTextEdit::updateRequest,
this, &QMarkdownTextEdit::updateLineNumberArea);
updateSettings();
// workaround for disabled signals up initialization
QTimer::singleShot(300, this, &QMarkdownTextEdit::adjustRightMargin);
}
void QMarkdownTextEdit::setLineNumbersCurrentLineColor(QColor color) {
_lineNumArea->setCurrentLineColor(std::move(color));
}
void QMarkdownTextEdit::setLineNumbersOtherLineColor(QColor color) {
_lineNumArea->setOtherLineColor(std::move(color));
}
void QMarkdownTextEdit::setSearchWidgetDebounceDelay(uint debounceDelay)
{
_debounceDelay = debounceDelay;
searchWidget()->setDebounceDelay(_debounceDelay);
}
void QMarkdownTextEdit::setHighlightCurrentLine(bool set)
{
_highlightCurrentLine = set;
}
bool QMarkdownTextEdit::highlightCurrentLine()
{
return _highlightCurrentLine;
}
void QMarkdownTextEdit::setCurrentLineHighlightColor(const QColor &color)
{
_currentLineHighlightColor = color;
}
QColor QMarkdownTextEdit::currentLineHighlightColor()
{
return _currentLineHighlightColor;
}
/**
* Enables or disables the Markdown highlighting
*
* @param enabled
*/
void QMarkdownTextEdit::setHighlightingEnabled(bool enabled) {
if (_highlightingEnabled == enabled || _highlighter == nullptr) {
return;
}
_highlightingEnabled = enabled;
_highlighter->setDocument(enabled ? document() : Q_NULLPTR);
if (enabled) {
_highlighter->rehighlight();
}
}
/**
* @brief Returns if highlighting is enabled
* @return Returns true if highlighting is enabled, otherwise false
*/
bool QMarkdownTextEdit::highlightingEnabled() const {
return _highlightingEnabled && _highlighter != nullptr;
}
/**
* Leave a little space on the right side if the document is too long, so
* that the search buttons don't get visually blocked by the scroll bar
*/
void QMarkdownTextEdit::adjustRightMargin() {
QMargins margins = layout()->contentsMargins();
const int rightMargin =
document()->size().height() > viewport()->size().height() ? 24 : 0;
margins.setRight(rightMargin);
layout()->setContentsMargins(margins);
}
bool QMarkdownTextEdit::eventFilter(QObject *obj, QEvent *event) {
// qDebug() << event->type();
if (event->type() == QEvent::HoverMove) {
auto *mouseEvent = static_cast<QMouseEvent *>(event);
QWidget *viewPort = this->viewport();
// toggle cursor when control key has been pressed or released
viewPort->setCursor(
mouseEvent->modifiers().testFlag(Qt::ControlModifier)
? Qt::PointingHandCursor
: Qt::IBeamCursor);
} else if (event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
// set cursor to pointing hand if control key was pressed
if (keyEvent->modifiers().testFlag(Qt::ControlModifier)) {
QWidget *viewPort = this->viewport();
viewPort->setCursor(Qt::PointingHandCursor);
}
// disallow keys if text edit hasn't focus
if (!this->hasFocus()) {
return true;
}
if ((keyEvent->key() == Qt::Key_Escape) && _searchWidget->isVisible()) {
_searchWidget->deactivate();
return true;
} else if ((keyEvent->key() == Qt::Key_Tab) ||
(keyEvent->key() == Qt::Key_Backtab)) {
// handle entered tab and reverse tab keys
return handleTabEntered(keyEvent->key() == Qt::Key_Backtab);
} else if ((keyEvent->key() == Qt::Key_F) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier)) {
_searchWidget->activate();
return true;
} else if ((keyEvent->key() == Qt::Key_R) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier)) {
_searchWidget->activateReplace();
return true;
// } else if (keyEvent->key() == Qt::Key_Delete) {
} else if (keyEvent->key() == Qt::Key_Backspace) {
return handleBackspaceEntered();
} else if (keyEvent->key() == Qt::Key_Asterisk) {
return handleBracketClosing(QLatin1Char('*'));
} else if (keyEvent->key() == Qt::Key_QuoteDbl) {
return quotationMarkCheck(QLatin1Char('"'));
// apostrophe bracket closing is temporary disabled because
// apostrophes are used in different contexts
// } else if (keyEvent->key() == Qt::Key_Apostrophe) {
// return handleBracketClosing("'");
// underline bracket closing is temporary disabled because
// underlines are used in different contexts
// } else if (keyEvent->key() == Qt::Key_Underscore) {
// return handleBracketClosing("_");
} else if (keyEvent->key() == Qt::Key_QuoteLeft) {
return quotationMarkCheck(QLatin1Char('`'));
} else if (keyEvent->key() == Qt::Key_AsciiTilde) {
return handleBracketClosing(QLatin1Char('~'));
#ifdef Q_OS_MAC
} else if (keyEvent->modifiers().testFlag(Qt::AltModifier) &&
keyEvent->key() == Qt::Key_ParenLeft) {
// bracket closing for US keyboard on macOS
return handleBracketClosing(QLatin1Char('{'), QLatin1Char('}'));
#endif
} else if (keyEvent->key() == Qt::Key_ParenLeft) {
return handleBracketClosing(QLatin1Char('('), QLatin1Char(')'));
} else if (keyEvent->key() == Qt::Key_BraceLeft) {
return handleBracketClosing(QLatin1Char('{'), QLatin1Char('}'));
} else if (keyEvent->key() == Qt::Key_BracketLeft) {
return handleBracketClosing(QLatin1Char('['), QLatin1Char(']'));
} else if (keyEvent->key() == Qt::Key_Less) {
return handleBracketClosing(QLatin1Char('<'), QLatin1Char('>'));
#ifdef Q_OS_MAC
} else if (keyEvent->modifiers().testFlag(Qt::AltModifier) &&
keyEvent->key() == Qt::Key_ParenRight) {
// bracket closing for US keyboard on macOS
return bracketClosingCheck(QLatin1Char('{'), QLatin1Char('}'));
#endif
} else if (keyEvent->key() == Qt::Key_ParenRight) {
return bracketClosingCheck(QLatin1Char('('), QLatin1Char(')'));
} else if (keyEvent->key() == Qt::Key_BraceRight) {
return bracketClosingCheck(QLatin1Char('{'), QLatin1Char('}'));
} else if (keyEvent->key() == Qt::Key_BracketRight) {
return bracketClosingCheck(QLatin1Char('['), QLatin1Char(']'));
} else if (keyEvent->key() == Qt::Key_Greater) {
return bracketClosingCheck(QLatin1Char('<'), QLatin1Char('>'));
} else if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) &&
keyEvent->modifiers().testFlag(Qt::ShiftModifier)) {
QTextCursor cursor = this->textCursor();
cursor.insertText(" \n");
return true;
} else if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier)) {
QTextCursor cursor = this->textCursor();
cursor.movePosition(QTextCursor::EndOfBlock);
cursor.insertText(QStringLiteral("\n"));
setTextCursor(cursor);
return true;
} else if (keyEvent == QKeySequence::Copy ||
keyEvent == QKeySequence::Cut) {
QTextCursor cursor = this->textCursor();
if (!cursor.hasSelection()) {
QString text;
if (cursor.block().length() <= 1) // no content
text = "\n";
else {
// cursor.select(QTextCursor::BlockUnderCursor); //
// negative, it will include the previous paragraph
// separator
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.movePosition(QTextCursor::EndOfBlock,
QTextCursor::KeepAnchor);
text = cursor.selectedText();
if (!cursor.atEnd()) {
text += "\n";
// this is the paragraph separator
cursor.movePosition(QTextCursor::NextCharacter,
QTextCursor::KeepAnchor, 1);
}
}
if (keyEvent == QKeySequence::Cut) {
if (!cursor.atEnd() && text == "\n")
cursor.deletePreviousChar();
else
cursor.removeSelectedText();
cursor.movePosition(QTextCursor::StartOfBlock);
setTextCursor(cursor);
}
qApp->clipboard()->setText(text);
return true;
}
} else if ((keyEvent->key() == Qt::Key_Down) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier) &&
keyEvent->modifiers().testFlag(Qt::AltModifier)) {
// duplicate text with `Ctrl + Alt + Down`
duplicateText();
return true;
#ifndef Q_OS_MAC
} else if ((keyEvent->key() == Qt::Key_Down) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier) &&
!keyEvent->modifiers().testFlag(Qt::ShiftModifier)) {
// scroll the page down
auto *scrollBar = verticalScrollBar();
scrollBar->setSliderPosition(scrollBar->sliderPosition() + 1);
return true;
} else if ((keyEvent->key() == Qt::Key_Up) &&
keyEvent->modifiers().testFlag(Qt::ControlModifier) &&
!keyEvent->modifiers().testFlag(Qt::ShiftModifier)) {
// scroll the page up
auto *scrollBar = verticalScrollBar();
scrollBar->setSliderPosition(scrollBar->sliderPosition() - 1);
return true;
#endif
} else if ((keyEvent->key() == Qt::Key_Down) &&
keyEvent->modifiers().testFlag(Qt::NoModifier)) {
// if you are in the last line and press cursor down the cursor will
// jump to the end of the line
QTextCursor cursor = textCursor();
if (cursor.position() >= document()->lastBlock().position()) {
cursor.movePosition(QTextCursor::EndOfLine);
// check if we are really in the last line, not only in
// the last block
if (cursor.atBlockEnd()) {
setTextCursor(cursor);
}
}
return QPlainTextEdit::eventFilter(obj, event);
} else if ((keyEvent->key() == Qt::Key_Up) &&
keyEvent->modifiers().testFlag(Qt::NoModifier)) {
// if you are in the first line and press cursor up the cursor will
// jump to the start of the line
QTextCursor cursor = textCursor();
QTextBlock block = document()->firstBlock();
int endOfFirstLinePos = block.position() + block.length();
if (cursor.position() <= endOfFirstLinePos) {
cursor.movePosition(QTextCursor::StartOfLine);
// check if we are really in the first line, not only in
// the first block
if (cursor.atBlockStart()) {
setTextCursor(cursor);
}
}
return QPlainTextEdit::eventFilter(obj, event);
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
return handleReturnEntered();
} else if ((keyEvent->key() == Qt::Key_F3)) {
_searchWidget->doSearch(
!keyEvent->modifiers().testFlag(Qt::ShiftModifier));
return true;
} else if ((keyEvent->key() == Qt::Key_Z) &&
(keyEvent->modifiers().testFlag(Qt::ControlModifier)) &&
!(keyEvent->modifiers().testFlag(Qt::ShiftModifier))) {
undo();
return true;
} else if ((keyEvent->key() == Qt::Key_Down) &&
(keyEvent->modifiers().testFlag(Qt::ControlModifier)) &&
(keyEvent->modifiers().testFlag(Qt::ShiftModifier))) {
moveTextUpDown(false);
return true;
} else if ((keyEvent->key() == Qt::Key_Up) &&
(keyEvent->modifiers().testFlag(Qt::ControlModifier)) &&
(keyEvent->modifiers().testFlag(Qt::ShiftModifier))) {
moveTextUpDown(true);
return true;
#ifdef Q_OS_MAC
// https://github.com/pbek/QOwnNotes/issues/1593
// https://github.com/pbek/QOwnNotes/issues/2643
} else if (keyEvent->key() == Qt::Key_Home) {
QTextCursor cursor = textCursor();
// Meta is Control on macOS
cursor.movePosition(
keyEvent->modifiers().testFlag(Qt::MetaModifier) ?
QTextCursor::Start : QTextCursor::StartOfLine,
keyEvent->modifiers().testFlag(Qt::ShiftModifier) ?
QTextCursor::KeepAnchor : QTextCursor::MoveAnchor);
this->setTextCursor(cursor);
return true;
} else if (keyEvent->key() == Qt::Key_End) {
QTextCursor cursor = textCursor();
// Meta is Control on macOS
cursor.movePosition(
keyEvent->modifiers().testFlag(Qt::MetaModifier) ?
QTextCursor::End : QTextCursor::EndOfLine,
keyEvent->modifiers().testFlag(Qt::ShiftModifier) ?
QTextCursor::KeepAnchor : QTextCursor::MoveAnchor);
this->setTextCursor(cursor);
return true;
#endif
}
return QPlainTextEdit::eventFilter(obj, event);
} else if (event->type() == QEvent::KeyRelease) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
// reset cursor if control key was released
if (keyEvent->key() == Qt::Key_Control) {
resetMouseCursor();
}
return QPlainTextEdit::eventFilter(obj, event);
} else if (event->type() == QEvent::MouseButtonRelease) {
_mouseButtonDown = false;
auto *mouseEvent = static_cast<QMouseEvent *>(event);
// track `Ctrl + Click` in the text edit
if ((obj == this->viewport()) &&
(mouseEvent->button() == Qt::LeftButton) &&
(QGuiApplication::keyboardModifiers() == Qt::ExtraButton24)) {
// open the link (if any) at the current position
// in the noteTextEdit
openLinkAtCursorPosition();
return true;
}
} else if (event->type() == QEvent::MouseButtonPress) {
_mouseButtonDown = true;
} else if (event->type() == QEvent::MouseButtonDblClick) {
_mouseButtonDown = true;
} else if (event->type() == QEvent::Wheel) {
auto *wheel = dynamic_cast<QWheelEvent*>(event);
// emit zoom signals
if (wheel->modifiers() == Qt::ControlModifier) {
if (wheel->angleDelta().y() > 0) {
Q_EMIT zoomIn();
} else {
Q_EMIT zoomOut();
}
return true;
}
}
return QPlainTextEdit::eventFilter(obj, event);
}
void QMarkdownTextEdit::centerTheCursor() {
if (_mouseButtonDown || !_centerCursor) {
return;
}
// centers the cursor every time, but not on the top and bottom
// bottom is done by setCenterOnScroll() in updateSettings()
centerCursor();
/*
QRect cursor = cursorRect();
QRect vp = viewport()->rect();
qDebug() << __func__ << " - 'cursor.top': " << cursor.top();
qDebug() << __func__ << " - 'cursor.bottom': " << cursor.bottom();
qDebug() << __func__ << " - 'vp': " << vp.bottom();
int bottom = 0;
int top = 0;
qDebug() << __func__ << " - 'viewportMargins().top()': "
<< viewportMargins().top();
qDebug() << __func__ << " - 'viewportMargins().bottom()': "
<< viewportMargins().bottom();
int vpBottom = viewportMargins().top() + viewportMargins().bottom() +
vp.bottom(); int vpCenter = vpBottom / 2; int cBottom = cursor.bottom() +
viewportMargins().top();
qDebug() << __func__ << " - 'vpBottom': " << vpBottom;
qDebug() << __func__ << " - 'vpCenter': " << vpCenter;
qDebug() << __func__ << " - 'cBottom': " << cBottom;
if (cBottom >= vpCenter) {
bottom = cBottom + viewportMargins().top() / 2 +
viewportMargins().bottom() / 2 - (vp.bottom() / 2);
// bottom = cBottom - (vp.bottom() / 2);
// bottom *= 1.5;
}
// setStyleSheet(QString("QPlainTextEdit {padding-bottom:
%1px;}").arg(QString::number(bottom)));
// if (cursor.top() < (vp.bottom() / 2)) {
// top = (vp.bottom() / 2) - cursor.top() + viewportMargins().top() /
2 + viewportMargins().bottom() / 2;
//// top *= -1;
//// bottom *= 1.5;
// }
qDebug() << __func__ << " - 'top': " << top;
qDebug() << __func__ << " - 'bottom': " << bottom;
setViewportMargins(0,top,0, bottom);
// QScrollBar* scrollbar = verticalScrollBar();
//
// qDebug() << __func__ << " - 'scrollbar->value();': " <<
scrollbar->value();;
// qDebug() << __func__ << " - 'scrollbar->maximum();': "
// << scrollbar->maximum();;
// scrollbar->setValue(scrollbar->value() - offset.y());
//
// setViewportMargins
// setViewportMargins(0, 0, 0, bottom);
*/
}
/*
* Handle the undo event ourselves
* Retains the selected text as selected after undo if
* bracket closing was used otherwise performs normal undo
*/
void QMarkdownTextEdit::undo() {
QTextCursor cursor = textCursor();
// if no text selected, call undo
if (!cursor.hasSelection()) {
QPlainTextEdit::undo();
return;
}
// if text is selected and bracket closing was used
// we retain our selection
if (_handleBracketClosingUsed) {
// get the selection
int selectionEnd = cursor.selectionEnd();
int selectionStart = cursor.selectionStart();
// call undo
QPlainTextEdit::undo();
// select again
cursor.setPosition(selectionStart - 1);
cursor.setPosition(selectionEnd - 1, QTextCursor::KeepAnchor);
this->setTextCursor(cursor);
_handleBracketClosingUsed = false;
} else {
// if text was selected but bracket closing wasn't used
// do normal undo
QPlainTextEdit::undo();
return;
}
}
void QMarkdownTextEdit::moveTextUpDown(bool up) {
QTextCursor cursor = textCursor();
QTextCursor move = cursor;
move.setVisualNavigation(false);
move.beginEditBlock(); // open an edit block to keep undo operations sane
bool hasSelection = cursor.hasSelection();
if (hasSelection) {
// if there's a selection inside the block, we select the whole block
move.setPosition(cursor.selectionStart());
move.movePosition(QTextCursor::StartOfBlock);
move.setPosition(cursor.selectionEnd(), QTextCursor::KeepAnchor);
move.movePosition(
move.atBlockStart() ? QTextCursor::Left : QTextCursor::EndOfBlock,
QTextCursor::KeepAnchor);
} else {
move.movePosition(QTextCursor::StartOfBlock);
move.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
}
// get the text of the current block
QString text = move.selectedText();
move.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor);
move.removeSelectedText();
if (up) { // up key
move.movePosition(QTextCursor::PreviousBlock);
move.insertBlock();
move.movePosition(QTextCursor::Left);
} else { // down key
move.movePosition(QTextCursor::EndOfBlock);
if (move.atBlockStart()) { // empty block
move.movePosition(QTextCursor::NextBlock);
move.insertBlock();
move.movePosition(QTextCursor::Left);
} else {
move.insertBlock();
}
}
int start = move.position();
move.clearSelection();
move.insertText(text);
int end = move.position();
// reselect
if (hasSelection) {
move.setPosition(end);
move.setPosition(start, QTextCursor::KeepAnchor);
} else {
move.setPosition(start);
}
move.endEditBlock();
setTextCursor(move);
}
void QMarkdownTextEdit::setLineNumberEnabled(bool enabled)
{
_lineNumArea->setLineNumAreaEnabled(enabled);
updateLineNumberAreaWidth(0);
}
/**
* Resets the cursor to Qt::IBeamCursor
*/
void QMarkdownTextEdit::resetMouseCursor() const {
QWidget *viewPort = viewport();
viewPort->setCursor(Qt::IBeamCursor);
}
/**
* Resets the cursor to Qt::IBeamCursor if the widget looses the focus
*/
void QMarkdownTextEdit::focusOutEvent(QFocusEvent *event) {
resetMouseCursor();
QPlainTextEdit::focusOutEvent(event);
}
/**
* Enters a closing character after an opening character if needed
*
* @param openingCharacter
* @param closingCharacter
* @return
*/
bool QMarkdownTextEdit::handleBracketClosing(const QChar openingCharacter,
QChar closingCharacter) {
// check if bracket closing or read-only are enabled
if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) {
return false;
}
QTextCursor cursor = textCursor();
if (closingCharacter.isNull()) {
closingCharacter = openingCharacter;
}
const QString selectedText = cursor.selectedText();
// When user currently has text selected, we prepend the openingCharacter
// and append the closingCharacter. E.g. 'text' -> '(text)'. We keep the
// current selectedText selected.
if (!selectedText.isEmpty()) {
// Insert. The selectedText is overwritten.
const QString newText =
openingCharacter + selectedText + closingCharacter;
cursor.insertText(newText);
// Re-select the selectedText.
const int selectionEnd = cursor.position() - 1;
const int selectionStart = selectionEnd - selectedText.length();
cursor.setPosition(selectionStart);
cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor);
this->setTextCursor(cursor);
_handleBracketClosingUsed = true;
return true;
}
// get the current text from the block (inserted character not included)
// Remove whitespace at start of string (e.g. in multilevel-lists).
const QString text = cursor.block().text().remove(QRegularExpression("^\\s+"));
const int pib = cursor.positionInBlock();
bool isPreviousAsterisk = pib > 0 && pib < text.length() && text.at(pib - 1) == '*';
bool isNextAsterisk = pib < text.length() && text.at(pib) == '*';
bool isMaybeBold = isPreviousAsterisk && isNextAsterisk;
if (pib < text.length() && !isMaybeBold && !text.at(pib).isSpace()) {
return false;
}
// Default positions to move the cursor back.
int cursorSubtract = 1;
// Special handling for `*` opening character, as this could be:
// - start of a list (or sublist);
// - start of a bold text;
if (openingCharacter == QLatin1Char('*')) {
// don't auto complete in code block
bool isInCode =
MarkdownHighlighter::isCodeBlock(cursor.block().userState());
// we only do auto completion if there is a space before the cursor pos
bool hasSpaceOrAsteriskBefore = !text.isEmpty() && pib > 0 &&
(text.at(pib - 1).isSpace() ||
text.at(pib - 1) == QLatin1Char('*'));
// This could be the start of a list, don't autocomplete.
bool isEmpty = text.isEmpty();
if (isInCode || !hasSpaceOrAsteriskBefore || isEmpty) {
return false;
}
// bold
if (isPreviousAsterisk && isNextAsterisk) {
cursorSubtract = 1;
}
// User wants: '**'.
// Not the start of a list, probably bold text. We autocomplete with
// extra closingCharacter and cursorSubtract to 'catchup'.
if (text == QLatin1String("*")) {
cursor.insertText(QStringLiteral("*"));
cursorSubtract = 2;
}
}
// Auto completion for ``` pair
if (openingCharacter == QLatin1Char('`')) {
#if QT_VERSION < QT_VERSION_CHECK(5, 12, 0)
if (QRegExp(QStringLiteral("[^`]*``")).exactMatch(text)) {
#else
if (QRegularExpression(QRegularExpression::anchoredPattern(QStringLiteral("[^`]*``"))).match(text).hasMatch()) {
#endif
cursor.insertText(QStringLiteral("``"));
cursorSubtract = 3;
}
}
// don't auto complete in code block
if (openingCharacter == QLatin1Char('<') &&
MarkdownHighlighter::isCodeBlock(cursor.block().userState())) {
return false;
}
cursor.beginEditBlock();
cursor.insertText(openingCharacter);
cursor.insertText(closingCharacter);
cursor.setPosition(cursor.position() - cursorSubtract);
cursor.endEditBlock();
setTextCursor(cursor);
return true;
}
/**
* Checks if the closing character should be output or not
*
* @param openingCharacter
* @param closingCharacter
* @return
*/
bool QMarkdownTextEdit::bracketClosingCheck(const QChar openingCharacter,
QChar closingCharacter) {
// check if bracket closing or read-only are enabled
if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) {
return false;
}
if (closingCharacter.isNull()) {
closingCharacter = openingCharacter;
}
QTextCursor cursor = textCursor();
const int positionInBlock = cursor.positionInBlock();
// get the current text from the block
const QString text = cursor.block().text();
const int textLength = text.length();
// if we are at the end of the line we just want to enter the character
if (positionInBlock >= textLength) {
return false;
}
const QChar currentChar = text.at(positionInBlock);
// if (closingCharacter == openingCharacter) {
// }
qDebug() << __func__ << " - 'currentChar': " << currentChar;
// if the current character is not the closing character we just want to
// enter the character
if (currentChar != closingCharacter) {
return false;
}
const QString leftText = text.left(positionInBlock);
const int openingCharacterCount = leftText.count(openingCharacter);
const int closingCharacterCount = leftText.count(closingCharacter);
// if there were enough opening characters just enter the character
if (openingCharacterCount < (closingCharacterCount + 1)) {
return false;
}
// move the cursor to the right and don't enter the character
cursor.movePosition(QTextCursor::Right);
setTextCursor(cursor);
return true;
}
/**
* Checks if the closing character should be output or not or if a closing
* character after an opening character if needed
*
* @param quotationCharacter
* @return
*/
bool QMarkdownTextEdit::quotationMarkCheck(const QChar quotationCharacter) {
// check if bracket closing or read-only are enabled
if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) {
return false;
}
QTextCursor cursor = textCursor();
const int positionInBlock = cursor.positionInBlock();
// get the current text from the block
const QString text = cursor.block().text();
const int textLength = text.length();
// if last char is not space, we are at word end, no autocompletion
const bool isBacktick = quotationCharacter == '`';
if (!isBacktick && positionInBlock != 0 &&
!text.at(positionInBlock - 1).isSpace()) {
return false;
}
// if we are at the end of the line we just want to enter the character
if (positionInBlock >= textLength) {
return handleBracketClosing(quotationCharacter);
}
const QChar currentChar = text.at(positionInBlock);
// if the current character is not the quotation character we just want to
// enter the character
if (currentChar != quotationCharacter) {
return handleBracketClosing(quotationCharacter);
}
// move the cursor to the right and don't enter the character
cursor.movePosition(QTextCursor::Right);
setTextCursor(cursor);
return true;
}
/***********************************
* helper methods for char removal
* Rules for (') and ("):
* if [sp]" -> opener (sp = space)
* if "[sp] -> closer
***********************************/
bool isQuotOpener(int position, const QString &text) {
if (position == 0) return true;
const int prevCharPos = position - 1;
return text.at(prevCharPos).isSpace();
}
bool isQuotCloser(int position, const QString &text) {
const int nextCharPos = position + 1;
if (nextCharPos >= text.length()) return true;
return text.at(nextCharPos).isSpace();
}
/**
* Handles removing of matching brackets and other Markdown characters
* Only works with backspace to remove text
*
* @return
*/
bool QMarkdownTextEdit::handleBackspaceEntered() {
if (!(_autoTextOptions & AutoTextOption::BracketRemoval) || isReadOnly()) {
return false;
}
QTextCursor cursor = textCursor();
// return if some text was selected
if (!cursor.selectedText().isEmpty()) {
return false;
}
int position = cursor.position();
const int positionInBlock = cursor.positionInBlock();
int block = cursor.block().blockNumber();
if (_highlighter)
if (_highlighter->isPosInACodeSpan(block, positionInBlock - 1))
return false;
// return if backspace was pressed at the beginning of a block
if (positionInBlock == 0) {
return false;
}
// get the current text from the block
const QString text = cursor.block().text();
char charToRemove{};
// current char
const char charInFront = text.at(positionInBlock - 1).toLatin1();
if (charInFront == '*')
return handleCharRemoval(MarkdownHighlighter::RangeType::Emphasis,
block, positionInBlock - 1);
else if (charInFront == '`')
return handleCharRemoval(MarkdownHighlighter::RangeType::CodeSpan,
block, positionInBlock - 1);
//handle removal of ", ', and brackets
// is it opener?
int pos = _openingCharacters.indexOf(charInFront);
// for " and '
bool isOpener = false;
bool isCloser = false;
if (pos == 5 || pos == 6) {
isOpener = isQuotOpener(positionInBlock - 1, text);
} else {
isOpener = pos != -1;
}
if (isOpener) {
charToRemove = _closingCharacters.at(pos);
} else {
// is it closer?
pos = _closingCharacters.indexOf(charInFront);
if (pos == 5 || pos == 6)
isCloser = isQuotCloser(positionInBlock - 1, text);
else
isCloser = pos != -1;
if (isCloser)
charToRemove = _openingCharacters.at(pos);
else
return false;
}
int charToRemoveIndex = -1;
if (isOpener) {
bool closer = true;
charToRemoveIndex = text.indexOf(charToRemove, positionInBlock);
if (charToRemoveIndex == -1) return false;
if (pos == 5 || pos == 6)
closer = isQuotCloser(charToRemoveIndex, text);
if (!closer) return false;
cursor.setPosition(position + (charToRemoveIndex - positionInBlock));
cursor.deleteChar();
} else if (isCloser) {
charToRemoveIndex = text.lastIndexOf(charToRemove, positionInBlock - 2);
if (charToRemoveIndex == -1) return false;
bool opener = true;
if (pos == 5 || pos == 6)
opener = isQuotOpener(charToRemoveIndex, text);
if (!opener) return false;
const int pos = position - (positionInBlock - charToRemoveIndex);
cursor.setPosition(pos);
cursor.deleteChar();
position -= 1;
} else {
charToRemoveIndex = text.lastIndexOf(charToRemove, positionInBlock - 2);
if (charToRemoveIndex == -1) return false;
const int pos = position - (positionInBlock - charToRemoveIndex);
cursor.setPosition(pos);
cursor.deleteChar();
position -= 1;
}
// moving the cursor back to the old position so the previous character
// can be removed
cursor.setPosition(position);
setTextCursor(cursor);
return false;
}
bool QMarkdownTextEdit::handleCharRemoval(MarkdownHighlighter::RangeType type,
int block, int position)
{
if (!_highlighter)
return false;
auto range = _highlighter->findPositionInRanges(type, block, position);
if (range == QPair<int, int>{-1, -1})
return false;
int charToRemovePos = range.first;
if (position == range.first)
charToRemovePos = range.second;
QTextCursor cursor = textCursor();
auto gpos = cursor.position();
if (charToRemovePos > position) {
cursor.setPosition(gpos + (charToRemovePos - (position + 1)));
} else {
cursor.setPosition(gpos - (position - charToRemovePos + 1));
gpos--;
}
cursor.deleteChar();
cursor.setPosition(gpos);
setTextCursor(cursor);
return false;
}
void QMarkdownTextEdit::updateLineNumAreaGeometry()
{
const auto contentsRect = this->contentsRect();
const QRect newGeometry = {contentsRect.left(), contentsRect.top(),
_lineNumArea->sizeHint().width(), contentsRect.height()};
auto oldGeometry = _lineNumArea->geometry();
if (newGeometry != oldGeometry) {
_lineNumArea->setGeometry(newGeometry);
}
}
void QMarkdownTextEdit::resizeEvent(QResizeEvent *event)
{
QPlainTextEdit::resizeEvent(event);
updateLineNumAreaGeometry();
}
/**
* Increases (or decreases) the indention of the selected text
* (if there is a text selected) in the noteTextEdit
* @return
*/
bool QMarkdownTextEdit::increaseSelectedTextIndention(
bool reverse, const QString &indentCharacters) {
QTextCursor cursor = this->textCursor();
QString selectedText = cursor.selectedText();
if (!selectedText.isEmpty()) {
// Start the selection at start of the first block of the selection
int end = cursor.selectionEnd();
cursor.setPosition(cursor.selectionStart());
cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::MoveAnchor);
cursor.setPosition(end, QTextCursor::KeepAnchor);
this->setTextCursor(cursor);
selectedText = cursor.selectedText();
// we need this strange newline character we are getting in the
// selected text for newlines
const QString newLine =
QString::fromUtf8(QByteArray::fromHex(QByteArrayLiteral("e280a9")));
QString newText;
if (reverse) {
// un-indent text
// QSettings settings;
const int indentSize = indentCharacters == QStringLiteral("\t")
? 4
: indentCharacters.length();
// remove leading \t or spaces in following lines
newText = selectedText.replace(
QRegularExpression(newLine + QStringLiteral("(\\t| {1,") +
QString::number(indentSize) +
QStringLiteral("})")),
QStringLiteral("\n"));
// remove leading \t or spaces in first line
newText.remove(QRegularExpression(QStringLiteral("^(\\t| {1,") +
QString::number(indentSize) +
QStringLiteral("})")));
} else {
// replace trailing new line to prevent an indent of the line after
// the selection
newText = selectedText.replace(
QRegularExpression(QRegularExpression::escape(newLine) +
QStringLiteral("$")),
QStringLiteral("\n"));
// indent text
newText.replace(newLine, QStringLiteral("\n") + indentCharacters)
.prepend(indentCharacters);
// remove trailing \t
newText.remove(QRegularExpression(QStringLiteral("\\t$")));
}
// insert the new text
cursor.insertText(newText);
// update the selection to the new text
cursor.setPosition(cursor.position() - newText.size(),
QTextCursor::KeepAnchor);
this->setTextCursor(cursor);
return true;
} else if (reverse) {
const int indentSize = indentCharacters.length();
// do the check as often as we have characters to un-indent
for (int i = 1; i <= indentSize; i++) {
// if nothing was selected but we want to reverse the indention
// check if there is a \t in front or after the cursor and remove it
// if so
const int position = cursor.position();
if (!cursor.atStart()) {
// get character in front of cursor
cursor.setPosition(position - 1, QTextCursor::KeepAnchor);
}
// check for \t or space in front of cursor
QRegularExpression re(QStringLiteral("[\\t ]"));
QRegularExpressionMatch match = re.match(cursor.selectedText());
if (!match.hasMatch()) {
// (select to) check for \t or space after the cursor
cursor.setPosition(position);
if (!cursor.atEnd()) {
cursor.setPosition(position + 1, QTextCursor::KeepAnchor);
}
}
match = re.match(cursor.selectedText());
if (match.hasMatch()) {
cursor.removeSelectedText();
}
cursor = this->textCursor();
}
return true;
}
// else just insert indentCharacters
cursor.insertText(indentCharacters);
return true;
}
/**
* @brief Opens the link (if any) at the current cursor position
*/
bool QMarkdownTextEdit::openLinkAtCursorPosition() {
QTextCursor cursor = this->textCursor();
const int clickedPosition = cursor.position();
// select the text in the clicked block and find out on
// which position we clicked
cursor.movePosition(QTextCursor::StartOfBlock);
const int positionFromStart = clickedPosition - cursor.position();
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
const QString selectedText = cursor.selectedText();
// find out which url in the selected text was clicked
const QString urlString =
getMarkdownUrlAtPosition(selectedText, positionFromStart);
const QUrl url = QUrl(urlString);
const bool isRelativeFileUrl =
urlString.startsWith(QLatin1String("file://.."));
const bool isLegacyAttachmentUrl =
urlString.startsWith(QLatin1String("file://attachments"));
qDebug() << __func__ << " - 'emit urlClicked( urlString )': " << urlString;
Q_EMIT urlClicked(urlString);
if ((url.isValid() && isValidUrl(urlString)) || isRelativeFileUrl ||
isLegacyAttachmentUrl) {
// ignore some schemata
if (!(_ignoredClickUrlSchemata.contains(url.scheme()) ||
isRelativeFileUrl || isLegacyAttachmentUrl)) {
// open the url
openUrl(urlString);
}
return true;
}
return false;
}
/**
* Checks if urlString is a valid url
*
* @param urlString
* @return
*/
bool QMarkdownTextEdit::isValidUrl(const QString &urlString) {
const QRegularExpressionMatch match =
QRegularExpression(R"(^\w+:\/\/.+)").match(urlString);
return match.hasMatch();
}
/**
* Handles clicked urls
*
* examples:
* - <https://www.qownnotes.org> opens the webpage
* - <file:///path/to/my/file/QOwnNotes.pdf> opens the file
* "/path/to/my/file/QOwnNotes.pdf" if the operating system supports that
* handler
*/
void QMarkdownTextEdit::openUrl(const QString &urlString) {
qDebug() << "QMarkdownTextEdit " << __func__
<< " - 'urlString': " << urlString;
QDesktopServices::openUrl(QUrl(urlString));
}
/**
* @brief Returns the highlighter instance
* @return
*/
MarkdownHighlighter *QMarkdownTextEdit::highlighter() { return _highlighter; }
/**
* @brief Returns the searchWidget instance
* @return
*/
QPlainTextEditSearchWidget *QMarkdownTextEdit::searchWidget() {
return _searchWidget;
}
/**
* @brief Sets url schemata that will be ignored when clicked on
* @param urlSchemes
*/
void QMarkdownTextEdit::setIgnoredClickUrlSchemata(
QStringList ignoredUrlSchemata) {
_ignoredClickUrlSchemata = std::move(ignoredUrlSchemata);
}
/**
* @brief Returns a map of parsed Markdown urls with their link texts as key
*
* @param text
* @return parsed urls
*/
QMap<QString, QString> QMarkdownTextEdit::parseMarkdownUrlsFromText(
const QString &text) {
QMap<QString, QString> urlMap;
QRegularExpression regex;
QRegularExpressionMatchIterator iterator;
// match urls like this: <http://mylink>
// re = QRegularExpression("(<(.+?:\\/\\/.+?)>)");
regex = QRegularExpression(QStringLiteral("(<(.+?)>)"));
iterator = regex.globalMatch(text);
while (iterator.hasNext()) {
QRegularExpressionMatch match = iterator.next();
QString linkText = match.captured(1);
QString url = match.captured(2);
urlMap[linkText] = url;
}
// match urls like this: [this url](http://mylink)
// QRegularExpression re("(\\[.*?\\]\\((.+?:\\/\\/.+?)\\))");
regex = QRegularExpression(R"((\[.*?\]\((.+?)\)))");
iterator = regex.globalMatch(text);
while (iterator.hasNext()) {
QRegularExpressionMatch match = iterator.next();
QString linkText = match.captured(1);
QString url = match.captured(2);
urlMap[linkText] = url;
}
// match urls like this: http://mylink
regex = QRegularExpression(R"(\b\w+?:\/\/[^\s]+[^\s>\)])");
iterator = regex.globalMatch(text);
while (iterator.hasNext()) {
QRegularExpressionMatch match = iterator.next();
QString url = match.captured(0);
urlMap[url] = url;
}
// match urls like this: www.github.com
regex = QRegularExpression(R"(\bwww\.[^\s]+\.[^\s]+\b)");
iterator = regex.globalMatch(text);
while (iterator.hasNext()) {
QRegularExpressionMatch match = iterator.next();
QString url = match.captured(0);
urlMap[url] = QStringLiteral("http://") + url;
}
// match reference urls like this: [this url][1] with this later:
// [1]: http://domain
regex = QRegularExpression(R"((\[.*?\]\[(.+?)\]))");
iterator = regex.globalMatch(text);
while (iterator.hasNext()) {
QRegularExpressionMatch match = iterator.next();
QString linkText = match.captured(1);
QString referenceId = match.captured(2);
// search for the referenced url in the whole text edit
// QRegularExpression refRegExp(
// "\\[" + QRegularExpression::escape(referenceId) +
// "\\]: (.+?:\\/\\/.+)");
QRegularExpression refRegExp(QStringLiteral("\\[") +
QRegularExpression::escape(referenceId) +
QStringLiteral("\\]: (.+)"));
QRegularExpressionMatch urlMatch = refRegExp.match(toPlainText());
if (urlMatch.hasMatch()) {
QString url = urlMatch.captured(1);
urlMap[linkText] = url;
}
}
return urlMap;
}
/**
* @brief Returns the Markdown url at position
* @param text
* @param position
* @return url string
*/
QString QMarkdownTextEdit::getMarkdownUrlAtPosition(const QString &text,
int position) {
QString url;
// get a map of parsed Markdown urls with their link texts as key
const QMap<QString, QString> urlMap = parseMarkdownUrlsFromText(text);
QMap<QString, QString>::const_iterator i = urlMap.constBegin();
for (; i != urlMap.constEnd(); ++i) {
const QString &linkText = i.key();
const QString &urlString = i.value();
const int foundPositionStart = text.indexOf(linkText);
if (foundPositionStart >= 0) {
// calculate end position of found linkText
const int foundPositionEnd = foundPositionStart + linkText.size();
// check if position is in found string range
if ((position >= foundPositionStart) &&
(position <= foundPositionEnd)) {
url = urlString;
break;
}
}
}
return url;
}
/**
* @brief Duplicates the text in the text edit
*/
void QMarkdownTextEdit::duplicateText() {
QTextCursor cursor = this->textCursor();
QString selectedText = cursor.selectedText();
// duplicate line if no text was selected
if (selectedText.isEmpty()) {
const int position = cursor.position();
// select the whole line
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
const int positionDiff = cursor.position() - position;
selectedText = "\n" + cursor.selectedText();
// insert text with new line at end of the selected line
cursor.setPosition(cursor.selectionEnd());
cursor.insertText(selectedText);
// set the position to same position it was in the duplicated line
cursor.setPosition(cursor.position() - positionDiff);
} else {
// duplicate selected text
cursor.setPosition(cursor.selectionEnd());
const int selectionStart = cursor.position();
// insert selected text
cursor.insertText(selectedText);
const int selectionEnd = cursor.position();
// select the inserted text
cursor.setPosition(selectionStart);
cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor);
}
this->setTextCursor(cursor);
}
void QMarkdownTextEdit::setText(const QString &text) { setPlainText(text); }
void QMarkdownTextEdit::setPlainText(const QString &text) {
// clear the dirty blocks vector to increase performance and prevent
// a possible crash in QSyntaxHighlighter::rehighlightBlock
if (_highlighter)
_highlighter->clearDirtyBlocks();
QPlainTextEdit::setPlainText(text);
adjustRightMargin();
}
/**
* Uses another widget as parent for the search widget
*/
void QMarkdownTextEdit::initSearchFrame(QWidget *searchFrame, bool darkMode) {
_searchFrame = searchFrame;
// remove the search widget from our layout
layout()->removeWidget(_searchWidget);
QLayout *layout = _searchFrame->layout();
// create a grid layout for the frame and add the search widget to it
if (layout == nullptr) {
layout = new QVBoxLayout(_searchFrame);
layout->setSpacing(0);
layout->setContentsMargins(0, 0, 0, 0);
}
_searchWidget->setDarkMode(darkMode);
layout->addWidget(_searchWidget);
_searchFrame->setLayout(layout);
}
/**
* Hides the text edit and the search widget
*/
void QMarkdownTextEdit::hide() {
_searchWidget->hide();
QWidget::hide();
}
/**
* Handles an entered return key
*/
bool QMarkdownTextEdit::handleReturnEntered() {
if (isReadOnly()) {
return true;
}
QTextCursor cursor = this->textCursor();
const int position = cursor.position();
cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
const QString currentLineText = cursor.selectedText();
// if return is pressed and there is just an unordered list symbol then we
// want to remove the list symbol Valid listCharacters: '+ ', '-' , '* ', '+
// [ ] ', '+ [x] ', '- [ ] ', '- [-] ', '- [x] ', '* [ ] ', '* [x] '.
QRegularExpression regex(R"(^(\s*)([+|\-|\*] \[(x|-| |)\]|[+\-\*])(\s+)$)");
QRegularExpressionMatchIterator iterator =
regex.globalMatch(currentLineText);
if (iterator.hasNext()) {
cursor.removeSelectedText();
return true;
}
// if return is pressed and there is just an ordered list symbol then we
// want to remove the list symbol
regex = QRegularExpression(R"(^(\s*)(\d+[\.|\)])(\s+)$)");
iterator = regex.globalMatch(currentLineText);
if (iterator.hasNext()) {
qDebug() << cursor.selectedText();
cursor.removeSelectedText();
return true;
}
// Check if we are in an unordered list.
// We are in a list when we have '* ', '- ' or '+ ', possibly with preceding
// whitespace. If e.g. user has entered '**text**' and pressed enter - we
// don't want to do more list-stuff.
QString currentLine = currentLineText.trimmed();
QChar char0;
QChar char1;
if (currentLine.length() >= 1)
char0 = currentLine.at(0);
if (currentLine.length() >= 2)
char1 = currentLine.at(1);
const bool inList =
((char0 == QLatin1Char('*') || char0 == QLatin1Char('-') ||
char0 == QLatin1Char('+')) &&
char1 == QLatin1Char(' '));
if (inList) {
// if the current line starts with a list character (possibly after
// whitespaces) add the whitespaces at the next line too
// Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ',
// '- [x] ', '- [-] ', '* [ ] ', '* [x] '.
regex =
QRegularExpression(R"(^(\s*)([+|\-|\*] \[(x|-| |)\]|[+\-\*])(\s+))");
iterator = regex.globalMatch(currentLineText);
if (iterator.hasNext()) {
const QRegularExpressionMatch match = iterator.next();
const QString whitespaces = match.captured(1);
QString listCharacter = match.captured(2);
const QString whitespaceCharacter = match.captured(4);
// start new checkbox list item with an unchecked checkbox
iterator = QRegularExpression(R"(^([+|\-|\*]) \[(x| |)\])")
.globalMatch(listCharacter);
if (iterator.hasNext()) {
const QRegularExpressionMatch match = iterator.next();
const QString realListCharacter = match.captured(1);
listCharacter = realListCharacter + QStringLiteral(" [ ]");
}
cursor.setPosition(position);
cursor.insertText("\n" + whitespaces + listCharacter +
whitespaceCharacter);
// scroll to the cursor if we are at the bottom of the document
ensureCursorVisible();
return true;
}
}
// check for ordered lists and increment the list number in the next line
regex = QRegularExpression(R"(^(\s*)(\d+)([\.|\)])(\s+))");
iterator = regex.globalMatch(currentLineText);
if (iterator.hasNext()) {
const QRegularExpressionMatch match = iterator.next();
const QString whitespaces = match.captured(1);
const uint listNumber = match.captured(2).toUInt();
const QString listMarker = match.captured(3);
const QString whitespaceCharacter = match.captured(4);
cursor.setPosition(position);
cursor.insertText("\n" + whitespaces + QString::number(listNumber + 1) +
listMarker + whitespaceCharacter);
// scroll to the cursor if we are at the bottom of the document
ensureCursorVisible();
return true;
}
// intent next line with same whitespaces as in current line
regex = QRegularExpression(R"(^(\s+))");
iterator = regex.globalMatch(currentLineText);
if (iterator.hasNext()) {
const QRegularExpressionMatch match = iterator.next();
const QString whitespaces = match.captured(1);
cursor.setPosition(position);
cursor.insertText("\n" + whitespaces);
// scroll to the cursor if we are at the bottom of the document
ensureCursorVisible();
return true;
}
return false;
}
/**
* Handles entered tab or reverse tab keys
*/
bool QMarkdownTextEdit::handleTabEntered(bool reverse,
const QString &indentCharacters) {
if (isReadOnly()) {
return true;
}
QTextCursor cursor = this->textCursor();
// only check for lists if we haven't a text selected
if (cursor.selectedText().isEmpty()) {
cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
const QString currentLineText = cursor.selectedText();
// check if we want to indent or un-indent an ordered list
// Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ',
// '- [x] ', '- [-] ', '* [ ] ', '* [x] '.
QRegularExpression re(R"(^(\s*)([+|\-|\*] \[(x|-| )\]|[+\-\*])(\s+)$)");
QRegularExpressionMatchIterator i = re.globalMatch(currentLineText);
if (i.hasNext()) {
QRegularExpressionMatch match = i.next();
QString whitespaces = match.captured(1);
const QString listCharacter = match.captured(2);
const QString whitespaceCharacter = match.captured(4);
// add or remove one tabulator key
if (reverse) {
// remove one set of indentCharacters or a tabulator
whitespaces.remove(QRegularExpression(
QStringLiteral("^(\\t|") +
QRegularExpression::escape(indentCharacters) +
QStringLiteral(")")));
} else {
whitespaces += indentCharacters;
}
cursor.insertText(whitespaces + listCharacter +
whitespaceCharacter);
return true;
}
// check if we want to indent or un-indent an ordered list
re = QRegularExpression(R"(^(\s*)(\d+)([\.|\)])(\s+)$)");
i = re.globalMatch(currentLineText);
if (i.hasNext()) {
const QRegularExpressionMatch match = i.next();
QString whitespaces = match.captured(1);
const QString listCharacter = match.captured(2);
const QString listMarker = match.captured(3);
const QString whitespaceCharacter = match.captured(4);
// add or remove one tabulator key
if (reverse) {
whitespaces.chop(1);
} else {
whitespaces += indentCharacters;
}
cursor.insertText(whitespaces + listCharacter + listMarker +
whitespaceCharacter);
return true;
}
}
// check if we want to indent the whole text
return increaseSelectedTextIndention(reverse, indentCharacters);
}
/**
* Sets the auto text options
*/
void QMarkdownTextEdit::setAutoTextOptions(AutoTextOptions options) {
_autoTextOptions = options;
}
void QMarkdownTextEdit::updateLineNumberArea(const QRect rect, int dy)
{
if (dy)
_lineNumArea->scroll(0, dy);
else
_lineNumArea->update(0, rect.y(), _lineNumArea->sizeHint().width(), rect.height());
updateLineNumAreaGeometry();
if (rect.contains(viewport()->rect())) {
updateLineNumberAreaWidth(0);
}
}
void QMarkdownTextEdit::updateLineNumberAreaWidth(int)
{
QSignalBlocker blocker(this);
const auto oldMargins = viewportMargins();
const int width = _lineNumArea->isLineNumAreaEnabled() ?
_lineNumArea->sizeHint().width() + _lineNumberLeftMarginOffset :
oldMargins.left();
const auto newMargins = QMargins{width, oldMargins.top(), oldMargins.right(), oldMargins.bottom()};
if (newMargins != oldMargins) {
setViewportMargins(newMargins);
}
}
/**
* @param e
* @details This does two things
* 1. Overrides QPlainTextEdit::paintEvent to fix the RTL bug of QPlainTextEdit
* 2. Paints a rectangle around code block fences [Code taken from
* ghostwriter(which in turn is based on QPlaintextEdit::paintEvent() with
* modifications and minor improvements for our use
*/
void QMarkdownTextEdit::paintEvent(QPaintEvent *e) {
QTextBlock block = firstVisibleBlock();
QPainter painter(viewport());
const QRect viewportRect = viewport()->rect();
// painter.fillRect(viewportRect, Qt::transparent);
bool firstVisible = true;
QPointF offset(contentOffset());
QRectF blockAreaRect; // Code or block quote rect.
bool inBlockArea = false;
bool clipTop = false;
bool drawBlock = false;
qreal dy = 0.0;
bool done = false;
const QColor &color = MarkdownHighlighter::codeBlockBackgroundColor();
const int cornerRadius = 5;
while (block.isValid() && !done) {
const QRectF r = blockBoundingRect(block).translated(offset);
const int state = block.userState();
if (!inBlockArea && MarkdownHighlighter::isCodeBlock(state)) {
// skip the backticks
if (!block.text().startsWith(QLatin1String("```")) &&
!block.text().startsWith(QLatin1String("~~~"))) {
blockAreaRect = r;
dy = 0.0;
inBlockArea = true;
}
// If this is the first visible block within the viewport
// and if the previous block is part of the text block area,
// then the rectangle to draw for the block area will have
// its top clipped by the viewport and will need to be
// drawn specially.
const int prevBlockState = block.previous().userState();
if (firstVisible &&
MarkdownHighlighter::isCodeBlock(prevBlockState)) {
clipTop = true;
}
}
// Else if the block ends a text block area...
else if (inBlockArea && MarkdownHighlighter::isCodeBlockEnd(state)) {
drawBlock = true;
inBlockArea = false;
blockAreaRect.setHeight(dy);
}
// If the block is at the end of the document and ends a text
// block area...
//
if (inBlockArea && block == this->document()->lastBlock()) {
drawBlock = true;
inBlockArea = false;
dy += r.height();
blockAreaRect.setHeight(dy);
}
offset.ry() += r.height();
dy += r.height();
// If this is the last text block visible within the viewport...
if (offset.y() > viewportRect.height()) {
if (inBlockArea) {
blockAreaRect.setHeight(dy);
drawBlock = true;
}
// Finished drawing.
done = true;
}
// If this is the last text block visible within the viewport...
if (offset.y() > viewportRect.height()) {
if (inBlockArea) {
blockAreaRect.setHeight(dy);
drawBlock = true;
}
// Finished drawing.
done = true;
}
if (drawBlock) {
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
painter.setPen(Qt::NoPen);
painter.setBrush(QBrush(color));
// If the first visible block is "clipped" such that the previous
// block is part of the text block area, then only draw a rectangle
// with the bottom corners rounded, and with the top corners square
// to reflect that the first visible block is part of a larger block
// of text.
//
if (clipTop) {
QPainterPath path;
path.setFillRule(Qt::WindingFill);
path.addRoundedRect(blockAreaRect, cornerRadius, cornerRadius);
qreal adjustedHeight = blockAreaRect.height() / 2;
path.addRect(blockAreaRect.adjusted(0, 0, 0, -adjustedHeight));
painter.drawPath(path.simplified());
clipTop = false;
}
// Else draw the entire rectangle with all corners rounded.
else {
painter.drawRoundedRect(blockAreaRect, cornerRadius,
cornerRadius);
}
drawBlock = false;
}
// this fixes the RTL bug of QPlainTextEdit
// https://bugreports.qt.io/browse/QTBUG-7516
if (block.text().isRightToLeft()) {
QTextLayout *layout = block.layout();
// opt = document()->defaultTextOption();
QTextOption opt = QTextOption(Qt::AlignRight);
opt.setTextDirection(Qt::RightToLeft);
layout->setTextOption(opt);
}
// Current line highlight
QTextCursor cursor = textCursor();
if (highlightCurrentLine() && cursor.block() == block) {
QTextLine line = block.layout()->lineForTextPosition(cursor.positionInBlock());
QRectF lineRect = line.rect();
lineRect.moveTop(lineRect.top() + r.top());
lineRect.setLeft(0.);
lineRect.setRight(viewportRect.width());
painter.fillRect(lineRect.toAlignedRect(), currentLineHighlightColor());
}
block = block.next();
firstVisible = false;
}
painter.end();
QPlainTextEdit::paintEvent(e);
}
/**
* Overrides QPlainTextEdit::setReadOnly to fix a problem with Chinese and
* Japanese input methods
*
* @param ro
*/
void QMarkdownTextEdit::setReadOnly(bool ro) {
QPlainTextEdit::setReadOnly(ro);
// attempted to fix a problem with Chinese and Japanese input methods
// @see https://github.com/pbek/QOwnNotes/issues/976
setAttribute(Qt::WA_InputMethodEnabled, !isReadOnly());
}
void QMarkdownTextEdit::doSearch(
QString &searchText, QPlainTextEditSearchWidget::SearchMode searchMode) {
_searchWidget->setSearchText(searchText);
_searchWidget->setSearchMode(searchMode);
_searchWidget->doSearchCount();
_searchWidget->activate(false);
}
void QMarkdownTextEdit::hideSearchWidget(bool reset) {
_searchWidget->deactivate();
if (reset) {
_searchWidget->reset();
}
}
void QMarkdownTextEdit::updateSettings() {
// if true: centers the screen if cursor reaches bottom (but not top)
searchWidget()->setDebounceDelay(_debounceDelay);
setCenterOnScroll(_centerCursor);
}
void QMarkdownTextEdit::setLineNumberLeftMarginOffset(int offset) {
_lineNumberLeftMarginOffset = offset;
}
QMargins QMarkdownTextEdit::viewportMargins() {
return QPlainTextEdit::viewportMargins();
}