diff --git a/README.md b/README.md index 95205e4..d5573dc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.2.0**。 +当前版本 **0.3.0**。 ## 界面 @@ -20,6 +20,7 @@ 链接与图片、引用、有序 / 无序列表、任务列表、分隔线 - `Ctrl+1` ~ `Ctrl+6` 把选中行设成 1–6 级标题,再按一次变回正文 - `Ctrl+/` 把选中内容包成围栏代码块,再按一次拆掉 +- 默认「编辑模式」隐藏 Markdown 标记、标题按级别放大,光标所在行露出原始标记 - 行号栏、当前行高亮 - `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进 - 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号 @@ -30,15 +31,19 @@ - **文件 → 打开文件夹…**(`Ctrl+Shift+O`)选一个目录,递归扫描 `*.md` / `*.markdown` / `*.mdown` / `*.mkd` -- `Ctrl+B` 显示 / 隐藏侧栏 -- 点文件名即打开,当前文件在列表里高亮;右上角「刷新」重新扫描 +- 状态栏左下角的「侧栏」按钮或 `Ctrl+B` 显示 / 隐藏 +- 点文件名即打开,当前文件在列表里高亮 +- 每 5 秒自动扫描一次:目录里新增 / 删除的 Markdown 会自动出现在列表里 - 上次打开的文件夹会被记住,下次启动自动恢复 -### 源代码模式 +### 编辑模式 / 源代码模式 -左下角状态栏的 **``** 按钮(或 `Ctrl+Shift+M`)切到源代码模式: -关掉语法高亮、行号与当前行高亮,只显示文件原始内容,适合核对格式或复制纯文本。 -默认是编辑模式,按钮会显示成 `` 表示当前处于源码模式。 +默认是**编辑模式**:Markdown 标记不显示——标题按级别放大加粗、粗体真加粗、 +行内代码带底色、链接只留文字,看到的就是排版后的内容。光标移到哪一行, +那一行会露出 `#`、`**` 这些原始标记,可以直接修改。 + +点左下角的 **``**(或 `Ctrl+Shift+M`)切到**源代码模式**:所有标记原样显示, +只做着色,适合核对格式或复制纯文本;按钮会变成 ``。 ### 文件 diff --git a/VoiletMark.pro b/VoiletMark.pro index 7638386..efb8c0d 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,16 +1,18 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.2.0 +VERSION = 0.3.0 QT += core gui widgets CONFIG += c++17 warn_on -# 让 main.cpp 里的 QStringLiteral(VM_VERSION) 拿到 "0.1.0" +# 让 main.cpp 里的 QStringLiteral(VM_VERSION) 拿到版本号字符串 DEFINES += VM_VERSION=\\\"$$VERSION\\\" INCLUDEPATH += $$PWD/src +RESOURCES += resources.qrc + SOURCES += \ src/main.cpp \ src/MainWindow.cpp \ diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png index 64a947d..3bdb14b 100644 Binary files a/docs/screenshot-dark.png and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png index a35150e..f20163b 100644 Binary files a/docs/screenshot-light.png and b/docs/screenshot-light.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..d0374c9 Binary files /dev/null and b/logo.png differ diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..90517ff --- /dev/null +++ b/resources.qrc @@ -0,0 +1,5 @@ + + + logo.png + + diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index c9f6900..ff0b30b 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -193,6 +193,10 @@ void MainWindow::createActions() connect(m_sidebarAction, &QAction::toggled, this, [this](bool visible) { if (m_sidebar) m_sidebar->setVisible(visible); + if (m_sidebarButton) { + const QSignalBlocker blocker(m_sidebarButton); + m_sidebarButton->setChecked(visible); + } QSettings().setValue(QStringLiteral("window/sidebarVisible"), visible); }); @@ -230,16 +234,9 @@ void MainWindow::createSidebar() layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(6); - QHBoxLayout *header = new QHBoxLayout(); - header->setSpacing(4); m_folderLabel = new QLabel(tr("未打开文件夹"), content); m_folderLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - QToolButton *refreshButton = new QToolButton(content); - refreshButton->setText(tr("刷新")); - refreshButton->setToolTip(tr("重新扫描文件夹里的 Markdown 文件")); - header->addWidget(m_folderLabel, 1); - header->addWidget(refreshButton); - layout->addLayout(header); + layout->addWidget(m_folderLabel); m_fileList = new QListWidget(content); m_fileList->setFrameShape(QFrame::NoFrame); @@ -252,8 +249,13 @@ void MainWindow::createSidebar() addDockWidget(Qt::LeftDockWidgetArea, m_sidebar); resizeDocks({m_sidebar}, {250}, Qt::Horizontal); - connect(refreshButton, &QToolButton::clicked, this, &MainWindow::refreshFolder); connect(m_fileList, &QListWidget::itemClicked, this, &MainWindow::openSidebarItem); + + // 列表自己刷新:没有“刷新”按钮,每 5 秒比对一次文件集合,变了才重建。 + m_folderRefreshTimer = new QTimer(this); + m_folderRefreshTimer->setInterval(5000); + connect(m_folderRefreshTimer, &QTimer::timeout, this, &MainWindow::refreshFolder); + m_folderRefreshTimer->start(); } void MainWindow::createMenus() @@ -330,7 +332,16 @@ void MainWindow::createMenus() void MainWindow::createStatusBar() { - // 左下角的 开关:切到源代码模式。 + // 左下角:侧栏开关 + 源代码模式开关。 + m_sidebarButton = new QToolButton(this); + m_sidebarButton->setText(tr("侧栏")); + m_sidebarButton->setCheckable(true); + m_sidebarButton->setChecked(true); + m_sidebarButton->setCursor(Qt::PointingHandCursor); + m_sidebarButton->setToolTip(tr("显示 / 隐藏文件夹侧栏(Ctrl+B)")); + statusBar()->addWidget(m_sidebarButton); + connect(m_sidebarButton, &QToolButton::toggled, m_sidebarAction, &QAction::setChecked); + m_sourceButton = new QToolButton(this); m_sourceButton->setText(QStringLiteral("")); m_sourceButton->setCheckable(true); @@ -719,24 +730,13 @@ void MainWindow::openFolder(const QString &path) m_sidebarAction->setChecked(true); } -void MainWindow::refreshFolder() +QStringList MainWindow::scanFolder() const { - if (!m_fileList) - return; - - m_fileList->clear(); - - if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir()) { - m_folderLabel->setText(tr("未打开文件夹")); - m_folderLabel->setToolTip(tr("文件 → 打开文件夹…")); - return; - } + QStringList files; + if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir()) + return files; const QDir root(m_folderPath); - m_folderLabel->setText(root.dirName().isEmpty() ? m_folderPath : root.dirName()); - m_folderLabel->setToolTip(QDir::toNativeSeparators(m_folderPath)); - - QStringList files; QDirIterator iterator(m_folderPath, {QStringLiteral("*.md"), QStringLiteral("*.markdown"), QStringLiteral("*.mdown"), QStringLiteral("*.mkd")}, @@ -753,6 +753,47 @@ void MainWindow::refreshFolder() return root.relativeFilePath(left).compare(root.relativeFilePath(right), Qt::CaseInsensitive) < 0; }); + return files; +} + +void MainWindow::refreshFolder() +{ + if (!m_fileList) + return; + + if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir()) { + if (m_fileList->count() > 0 || m_displayedFolder != m_folderPath) { + m_fileList->clear(); + m_displayedFolder = m_folderPath; + m_folderLabel->setText(tr("未打开文件夹")); + m_folderLabel->setToolTip(tr("文件 → 打开文件夹…")); + } + return; + } + + const QDir root(m_folderPath); + if (m_displayedFolder != m_folderPath) { + m_displayedFolder = m_folderPath; + m_fileList->clear(); + m_folderLabel->setText(root.dirName().isEmpty() ? m_folderPath : root.dirName()); + m_folderLabel->setToolTip(QDir::toNativeSeparators(m_folderPath)); + } + + const QStringList files = scanFolder(); + + // 和当前显示的内容一致就什么都不做,避免每 5 秒闪一下、丢失选中项。 + QStringList shown; + shown.reserve(m_fileList->count()); + for (int index = 0; index < m_fileList->count(); ++index) { + const QString path = m_fileList->item(index)->data(Qt::UserRole).toString(); + if (!path.isEmpty()) + shown.append(path); + } + if (files == shown) + return; + + const QString selectedPath = m_currentPath; + m_fileList->clear(); for (const QString &filePath : files) { QListWidgetItem *item = new QListWidgetItem(root.relativeFilePath(filePath), m_fileList); @@ -765,7 +806,7 @@ void MainWindow::refreshFolder() empty->setFlags(Qt::NoItemFlags); } - selectInSidebar(m_currentPath); + selectInSidebar(selectedPath); } void MainWindow::openSidebarItem(QListWidgetItem *item) diff --git a/src/MainWindow.h b/src/MainWindow.h index e5091bd..f6f8ac7 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -54,6 +54,7 @@ private: void refreshFolder(); void openSidebarItem(QListWidgetItem *item); void selectInSidebar(const QString &path); + QStringList scanFolder() const; void setSourceModeEnabled(bool enabled); void changeZoom(int direction); @@ -76,7 +77,10 @@ private: QListWidget *m_fileList = nullptr; QLabel *m_folderLabel = nullptr; QToolButton *m_sourceButton = nullptr; + QToolButton *m_sidebarButton = nullptr; + QTimer *m_folderRefreshTimer = nullptr; QString m_folderPath; + QString m_displayedFolder; QString m_currentPath; Eol m_eol = Eol::Lf; diff --git a/src/MarkdownEditor.cpp b/src/MarkdownEditor.cpp index 555a4ac..58fac33 100644 --- a/src/MarkdownEditor.cpp +++ b/src/MarkdownEditor.cpp @@ -74,6 +74,8 @@ MarkdownEditor::MarkdownEditor(QWidget *parent) m_lineNumberArea = new LineNumberArea(this); m_highlighter = new MarkdownHighlighter(document()); + m_activeBlock = textCursor().blockNumber(); + m_highlighter->setActiveBlock(m_activeBlock); connect(this, &QPlainTextEdit::blockCountChanged, this, &MarkdownEditor::updateLineNumberAreaWidth); @@ -81,6 +83,8 @@ MarkdownEditor::MarkdownEditor(QWidget *parent) this, &MarkdownEditor::updateLineNumberArea); connect(this, &QPlainTextEdit::cursorPositionChanged, this, &MarkdownEditor::updateCurrentLineHighlight); + connect(this, &QPlainTextEdit::cursorPositionChanged, + this, &MarkdownEditor::updateActiveBlock); connect(ThemeManager::instance(), &ThemeManager::schemeChanged, this, &MarkdownEditor::applyTheme); @@ -159,11 +163,15 @@ void MarkdownEditor::applyEditorFont() document()->setDefaultFont(font); setTabStopDistance(QFontMetricsF(font).horizontalAdvance(QLatin1Char(' ')) * 4.0); updateLineNumberAreaWidth(0); + + // 标题字号是根据正文大小算的,缩放后要让高亮层重建格式。 + if (m_highlighter) + m_highlighter->refreshAppearance(); } void MarkdownEditor::updateLineNumberAreaWidth(int) { - setViewportMargins(m_sourceMode ? 0 : lineNumberAreaWidth(), 0, 0, 0); + setViewportMargins(lineNumberAreaWidth(), 0, 0, 0); } void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy) @@ -179,11 +187,6 @@ void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy) void MarkdownEditor::updateCurrentLineHighlight() { - if (m_sourceMode) { - setExtraSelections({}); - return; - } - QTextEdit::ExtraSelection selection; selection.format.setBackground(ThemeManager::instance()->colors().currentLine); selection.format.setProperty(QTextFormat::FullWidthSelection, true); @@ -205,24 +208,34 @@ void MarkdownEditor::setSourceMode(bool enabled) return; m_sourceMode = enabled; - m_highlighter->setHighlightingEnabled(!enabled); - - if (enabled) - setExtraSelections({}); - else - updateCurrentLineHighlight(); - - m_lineNumberArea->setVisible(!enabled); - updateLineNumberAreaWidth(0); - - const QRect rect = contentsRect(); - m_lineNumberArea->setGeometry(QRect(rect.left(), rect.top(), - lineNumberAreaWidth(), rect.height())); + m_activeBlock = textCursor().blockNumber(); + m_highlighter->setActiveBlock(m_activeBlock); + m_highlighter->setRendered(!enabled); + updateCurrentLineHighlight(); viewport()->update(); emit sourceModeChanged(enabled); } +void MarkdownEditor::updateActiveBlock() +{ + if (m_sourceMode) + return; + + const int blockNumber = textCursor().blockNumber(); + if (blockNumber == m_activeBlock) + return; + + const QTextBlock previous = document()->findBlockByNumber(m_activeBlock); + m_activeBlock = blockNumber; + m_highlighter->setActiveBlock(blockNumber); + + // 只重排两行:离开的那行重新隐藏标记,进入的那行露出来。 + if (previous.isValid()) + m_highlighter->rehighlightBlock(previous); + m_highlighter->rehighlightBlock(textCursor().block()); +} + void MarkdownEditor::selectedBlockRange(int *firstBlock, int *lastBlock) const { const QTextCursor cursor = textCursor(); diff --git a/src/MarkdownEditor.h b/src/MarkdownEditor.h index 51d2996..13e754f 100644 --- a/src/MarkdownEditor.h +++ b/src/MarkdownEditor.h @@ -48,6 +48,7 @@ private slots: void updateLineNumberAreaWidth(int newBlockCount); void updateLineNumberArea(const QRect &rect, int dy); void updateCurrentLineHighlight(); + void updateActiveBlock(); void applyTheme(); private: @@ -63,4 +64,5 @@ private: MarkdownHighlighter *m_highlighter = nullptr; int m_fontPointSize = 12; bool m_sourceMode = false; + int m_activeBlock = -1; }; diff --git a/src/MarkdownHighlighter.cpp b/src/MarkdownHighlighter.cpp index 4b6fa82..62eb268 100644 --- a/src/MarkdownHighlighter.cpp +++ b/src/MarkdownHighlighter.cpp @@ -2,7 +2,9 @@ #include "ThemeManager.h" +#include #include +#include namespace { constexpr int StateNormal = 0; @@ -19,13 +21,45 @@ MarkdownHighlighter::MarkdownHighlighter(QTextDocument *document) }); } +void MarkdownHighlighter::setRendered(bool rendered) +{ + if (m_rendered == rendered) + return; + m_rendered = rendered; + rehighlight(); +} + +void MarkdownHighlighter::setActiveBlock(int blockNumber) +{ + m_activeBlock = blockNumber; +} + +void MarkdownHighlighter::refreshAppearance() +{ + rebuildFormats(); + rehighlight(); +} + +bool MarkdownHighlighter::isActiveLine() const +{ + return m_rendered && currentBlock().blockNumber() == m_activeBlock; +} + void MarkdownHighlighter::rebuildFormats() { const ThemeColors &c = ThemeManager::instance()->colors(); - m_headingFormat = QTextCharFormat(); - m_headingFormat.setForeground(c.heading); - m_headingFormat.setFontWeight(QFont::DemiBold); + qreal base = 12.0; + if (document() && document()->defaultFont().pointSizeF() > 0.5) + base = document()->defaultFont().pointSizeF(); + + static const qreal scales[6] = {1.75, 1.50, 1.30, 1.14, 1.04, 1.0}; + for (int level = 0; level < 6; ++level) { + m_headingFormats[level] = QTextCharFormat(); + m_headingFormats[level].setForeground(c.heading); + m_headingFormats[level].setFontWeight(QFont::Bold); + m_headingFormats[level].setFontPointSize(base * scales[level]); + } m_headingMarkerFormat = QTextCharFormat(); m_headingMarkerFormat.setForeground(c.headingMarker); @@ -34,6 +68,11 @@ void MarkdownHighlighter::rebuildFormats() m_strongFormat.setForeground(c.strong); m_strongFormat.setFontWeight(QFont::Bold); + m_boldItalicFormat = QTextCharFormat(); + m_boldItalicFormat.setForeground(c.strong); + m_boldItalicFormat.setFontWeight(QFont::Bold); + m_boldItalicFormat.setFontItalic(true); + m_emphasisFormat = QTextCharFormat(); m_emphasisFormat.setForeground(c.emphasis); m_emphasisFormat.setFontItalic(true); @@ -46,6 +85,7 @@ void MarkdownHighlighter::rebuildFormats() m_linkFormat = QTextCharFormat(); m_linkFormat.setForeground(c.link); + m_linkFormat.setFontUnderline(true); m_urlFormat = QTextCharFormat(); m_urlFormat.setForeground(c.linkUrl); @@ -74,125 +114,234 @@ void MarkdownHighlighter::rebuildFormats() m_strikeFormat.setForeground(c.quote); } +QTextCharFormat MarkdownHighlighter::compose(const QTextCharFormat &specific) const +{ + QTextCharFormat result = m_blockBase; + result.merge(specific); + return result; +} + +QTextCharFormat MarkdownHighlighter::hiddenFormat(const QColor &background) const +{ + // 用“和背景同色 + 极小字号”把标记藏起来:文字仍然留在文档里, + // 光标移回来时重新显示,所以不影响编辑和撤销。 + QTextCharFormat format; + format.setForeground(background); + format.setFontPointSize(0.5); + return format; +} + void MarkdownHighlighter::apply(int start, int length, const QTextCharFormat &format) { if (length > 0) setFormat(start, length, format); } -void MarkdownHighlighter::applyPattern(const QRegularExpression &pattern, const QString &text, - const QTextCharFormat &format) +void MarkdownHighlighter::applyDelimited(const QRegularExpression &pattern, const QString &text, + const QTextCharFormat &contentFormat, int contentGroup, + const QColor &hideAgainst) { - auto it = pattern.globalMatch(text); - while (it.hasNext()) { - const QRegularExpressionMatch match = it.next(); - apply(match.capturedStart(), match.capturedLength(), format); - } -} + auto iterator = pattern.globalMatch(text); + while (iterator.hasNext()) { + const QRegularExpressionMatch match = iterator.next(); + const int start = match.capturedStart(contentGroup); + const int length = match.capturedLength(contentGroup); + if (length <= 0) + continue; -void MarkdownHighlighter::setHighlightingEnabled(bool enabled) -{ - if (m_enabled == enabled) - return; - m_enabled = enabled; - rehighlight(); + if (m_rendered && !isActiveLine()) { + apply(start, length, compose(contentFormat)); + const int matchStart = match.capturedStart(); + const int matchEnd = matchStart + match.capturedLength(); + if (start > matchStart) + apply(matchStart, start - matchStart, compose(hiddenFormat(hideAgainst))); + if (matchEnd > start + length) + apply(start + length, matchEnd - start - length, + compose(hiddenFormat(hideAgainst))); + } else { + // 源码模式,或光标正停在这一行:整段按内容样式显示,标记保留。 + apply(match.capturedStart(), match.capturedLength(), compose(contentFormat)); + } + } } void MarkdownHighlighter::highlightBlock(const QString &text) { setCurrentBlockState(StateNormal); - if (!m_enabled || text.isEmpty()) + m_blockBase = QTextCharFormat(); + if (text.isEmpty()) return; + const ThemeColors &c = ThemeManager::instance()->colors(); + const bool rendered = m_rendered; + const bool active = isActiveLine(); + static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)")); + const bool inFence = previousBlockState() == StateInFence; + const bool fenceLine = fencePattern.match(text).hasMatch(); - // ---- 围栏代码块:跨行状态机 ---------------------------------------- - if (previousBlockState() == StateInFence) { - apply(0, text.length(), m_codeFormat); - if (!fencePattern.match(text).hasMatch()) + // ---- 围栏代码块 ----------------------------------------------------- + if (inFence) { + if (!fenceLine) setCurrentBlockState(StateInFence); + if (rendered && fenceLine && !active) { + apply(0, text.length(), hiddenFormat(c.editorBase)); + } else { + m_blockBase = m_codeFormat; + apply(0, text.length(), m_codeFormat); + } return; } - if (fencePattern.match(text).hasMatch()) { - apply(0, text.length(), m_codeFormat); + if (fenceLine) { setCurrentBlockState(StateInFence); + if (rendered && !active) + apply(0, text.length(), hiddenFormat(c.editorBase)); + else + apply(0, text.length(), m_codeFormat); return; } - // ---- 块级语法 ------------------------------------------------------ - static const QRegularExpression headingPattern( - QStringLiteral("^\\s{0,3}(#{1,6})(?:\\s+|$)")); + // ---- 标题 ----------------------------------------------------------- + static const QRegularExpression headingPattern(QStringLiteral("^\\s{0,3}(#{1,6})(\\s+)")); const QRegularExpressionMatch heading = headingPattern.match(text); if (heading.hasMatch()) { - apply(0, text.length(), m_headingFormat); - apply(heading.capturedStart(1), heading.capturedLength(1), m_headingMarkerFormat); + const int level = qBound(1, heading.capturedLength(1), 6); + m_blockBase.merge(m_headingFormats[level - 1]); + apply(0, text.length(), m_blockBase); + + const int markerStart = heading.capturedStart(1); + const int markerLength = heading.capturedLength(1) + heading.capturedLength(2); + if (rendered && !active) + apply(markerStart, markerLength, compose(hiddenFormat(c.editorBase))); + else + apply(markerStart, markerLength, compose(m_headingMarkerFormat)); } + // ---- 引用 ----------------------------------------------------------- static const QRegularExpression quotePattern(QStringLiteral("^\\s{0,3}(>+)")); const QRegularExpressionMatch quote = quotePattern.match(text); if (quote.hasMatch()) { - apply(0, text.length(), m_quoteFormat); - apply(quote.capturedStart(1), quote.capturedLength(1), m_quoteMarkerFormat); + m_blockBase.merge(m_quoteFormat); + apply(0, text.length(), m_blockBase); + if (rendered && !active) + apply(quote.capturedStart(1), quote.capturedLength(1), + compose(hiddenFormat(c.editorBase))); + else + apply(quote.capturedStart(1), quote.capturedLength(1), compose(m_quoteMarkerFormat)); } + // ---- 分隔线 / 列表 --------------------------------------------------- static const QRegularExpression rulePattern( QStringLiteral("^\\s{0,3}(?:(?:\\*\\s*){3,}|(?:-\\s*){3,}|(?:_\\s*){3,})$")); - const QRegularExpressionMatch rule = rulePattern.match(text); - if (rule.hasMatch()) { - apply(0, text.length(), m_ruleFormat); + if (rulePattern.match(text).hasMatch()) { + apply(0, text.length(), compose(m_ruleFormat)); } else { static const QRegularExpression taskPattern( QStringLiteral("^\\s*[-+*]\\s+\\[([ xX])\\]")); const QRegularExpressionMatch task = taskPattern.match(text); if (task.hasMatch()) { - apply(task.capturedStart(), task.capturedLength(), m_listMarkerFormat); + apply(task.capturedStart(), task.capturedLength(), compose(m_listMarkerFormat)); if (task.capturedLength(1) > 0) { - const bool done = task.captured(1).compare(QLatin1String("x"), Qt::CaseInsensitive) == 0; + const bool done = task.captured(1).compare(QLatin1String("x"), + Qt::CaseInsensitive) == 0; apply(task.capturedStart(1), task.capturedLength(1), - done ? m_taskDoneFormat : m_listMarkerFormat); + compose(done ? m_taskDoneFormat : m_listMarkerFormat)); } } else { static const QRegularExpression listPattern( QStringLiteral("^(\\s*)([-+*]|\\d{1,9}[.)])(\\s+)")); const QRegularExpressionMatch list = listPattern.match(text); if (list.hasMatch()) - apply(list.capturedStart(2), list.capturedLength(2), m_listMarkerFormat); + apply(list.capturedStart(2), list.capturedLength(2), compose(m_listMarkerFormat)); } } - // ---- 行内语法 ------------------------------------------------------ + // ---- 链接 / 图片 ----------------------------------------------------- static const QRegularExpression linkPattern( QStringLiteral("!?\\[([^\\]]*)\\]\\(([^)\\s]*)(?:\\s+\"([^\"]*)\")?\\)")); auto links = linkPattern.globalMatch(text); while (links.hasNext()) { const QRegularExpressionMatch match = links.next(); - apply(match.capturedStart(1), match.capturedLength(1), m_linkFormat); - apply(match.capturedStart(2), match.capturedLength(2), m_urlFormat); + const int labelStart = match.capturedStart(1); + const int labelLength = match.capturedLength(1); + const int matchEnd = match.capturedStart() + match.capturedLength(); + + apply(labelStart, labelLength, compose(m_linkFormat)); + + if (rendered && !active) { + if (labelStart > match.capturedStart()) + apply(match.capturedStart(), labelStart - match.capturedStart(), + compose(hiddenFormat(c.editorBase))); + const int suffixStart = labelStart + labelLength; + if (matchEnd > suffixStart) + apply(suffixStart, matchEnd - suffixStart, + compose(hiddenFormat(c.editorBase))); + } else if (match.capturedLength(2) > 0) { + apply(match.capturedStart(2), match.capturedLength(2), compose(m_urlFormat)); + } } static const QRegularExpression autoLinkPattern(QStringLiteral("<(https?://[^>]+)>")); - auto autolinks = autoLinkPattern.globalMatch(text); - while (autolinks.hasNext()) { - const QRegularExpressionMatch match = autolinks.next(); - apply(match.capturedStart(1), match.capturedLength(1), m_urlFormat); + auto autoLinks = autoLinkPattern.globalMatch(text); + while (autoLinks.hasNext()) { + const QRegularExpressionMatch match = autoLinks.next(); + apply(match.capturedStart(1), match.capturedLength(1), compose(m_urlFormat)); + if (rendered && !active) { + apply(match.capturedStart(), 1, compose(hiddenFormat(c.editorBase))); + apply(match.capturedStart() + match.capturedLength() - 1, 1, + compose(hiddenFormat(c.editorBase))); + } } + // ---- 行内强调 -------------------------------------------------------- + // 内容里禁止出现定界符,否则 **粗体** 会被更宽泛的 *斜体* 规则先吃掉。 + static const QRegularExpression boldAsterisk( + QStringLiteral("\\*\\*(?=\\S)([^*\\n]+?)(?<=\\S)\\*\\*")); + applyDelimited(boldAsterisk, text, m_strongFormat, 1, c.editorBase); + + static const QRegularExpression boldUnderscore( + QStringLiteral("__(?=\\S)([^_\\n]+?)(?<=\\S)__")); + applyDelimited(boldUnderscore, text, m_strongFormat, 1, c.editorBase); + + static const QRegularExpression italicAsterisk( + QStringLiteral("(? matchStart) + apply(matchStart, contentStart - matchStart, hidden); + const int suffixStart = contentStart + contentLength; + if (matchEnd > suffixStart) + apply(suffixStart, matchEnd - suffixStart, hidden); + } + } } diff --git a/src/MarkdownHighlighter.h b/src/MarkdownHighlighter.h index 5d3d2fb..3ffa107 100644 --- a/src/MarkdownHighlighter.h +++ b/src/MarkdownHighlighter.h @@ -1,14 +1,20 @@ #pragma once +#include #include #include #include -/// Markdown 语法着色。 +/// Markdown 着色与“就地渲染”。 /// -/// 只做“逐行着色”,不改动文档内容:标题、引用、列表、行内样式、 -/// 链接以及跨行的围栏代码块。颜色全部取自 ThemeManager, -/// 主题切换后自动重建格式并重新着色。 +/// 两种形态: +/// - 渲染模式(默认,对应界面的“编辑器模式”):隐藏 #、**、反引号等标记, +/// 标题按级别放大、加粗文本真加粗、链接只留文字;光标所在行会露出原始标记, +/// 方便直接修改。 +/// - 源码模式:原样显示所有标记,只做着色。 +/// +/// 注意:QSyntaxHighlighter::setFormat 是“整段替换”而不是合并,所以这里 +/// 每一段都先和当前行的基础格式(标题 / 引用 / 代码块)合并后再设置。 class MarkdownHighlighter : public QSyntaxHighlighter { Q_OBJECT @@ -16,8 +22,15 @@ class MarkdownHighlighter : public QSyntaxHighlighter public: explicit MarkdownHighlighter(QTextDocument *document = nullptr); - /// 源代码模式会关掉着色,但不改文档内容(rehighlight 会清掉旧格式)。 - void setHighlightingEnabled(bool enabled); + void setRendered(bool rendered); + bool isRendered() const { return m_rendered; } + + /// 光标所在行;渲染模式下这一行不隐藏标记。 + void setActiveBlock(int blockNumber); + int activeBlock() const { return m_activeBlock; } + + /// 主题或字号变化后重建格式并重新着色。 + void refreshAppearance(); protected: void highlightBlock(const QString &text) override; @@ -25,12 +38,21 @@ protected: private: void rebuildFormats(); void apply(int start, int length, const QTextCharFormat &format); - void applyPattern(const QRegularExpression &pattern, const QString &text, - const QTextCharFormat &format); + void applyDelimited(const QRegularExpression &pattern, const QString &text, + const QTextCharFormat &contentFormat, int contentGroup, + const QColor &hideAgainst); + QTextCharFormat compose(const QTextCharFormat &specific) const; + QTextCharFormat hiddenFormat(const QColor &background) const; + bool isActiveLine() const; - QTextCharFormat m_headingFormat; + bool m_rendered = true; + int m_activeBlock = -1; + QTextCharFormat m_blockBase; + + QTextCharFormat m_headingFormats[6]; QTextCharFormat m_headingMarkerFormat; QTextCharFormat m_strongFormat; + QTextCharFormat m_boldItalicFormat; QTextCharFormat m_emphasisFormat; QTextCharFormat m_codeFormat; QTextCharFormat m_linkFormat; @@ -41,5 +63,4 @@ private: QTextCharFormat m_ruleFormat; QTextCharFormat m_taskDoneFormat; QTextCharFormat m_strikeFormat; - bool m_enabled = true; }; diff --git a/src/main.cpp b/src/main.cpp index a7dd5ea..3f2fa73 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); QApplication::setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); + QApplication::setWindowIcon(QIcon(QStringLiteral(":/logo.png"))); QCoreApplication::setOrganizationName(QStringLiteral("VoiletMark")); QCoreApplication::setApplicationName(QStringLiteral("VoiletMark"));