feat: v0.3.0 —— 默认编辑模式就地渲染,侧栏自动刷新

编辑模式 / 源代码模式语义修正
- 默认「编辑模式」不再显示 #、**、反引号等标记:标题按级别放大加粗、
  粗体真加粗、行内代码带底色、链接只保留文字
- 光标所在行会露出原始标记,方便直接修改;切换行时只重排这两行
- 源代码模式仍是原样显示 + 着色,左下角 <src> 切换

高亮器
- QSyntaxHighlighter::setFormat 是整段替换而非合并,改为每段先与
  当前行基础格式(标题 / 引用 / 代码块)compose 后再设置
- 强调正则收紧,避免 **粗体** 被 *斜体* 抢先匹配

侧栏
- 去掉刷新按钮,改为每 5 秒自动扫描;文件集合没变就不重建列表
- 状态栏左下角新增「侧栏」开关按钮,与 Ctrl+B 同步

其它
- 使用 logo.png 作为窗口图标(resources.qrc)
- 版本号 0.3.0,README 同步更新
This commit is contained in:
2026-10-22 17:30:28 +08:00
parent e1bece6378
commit dd9c79446e
13 changed files with 368 additions and 124 deletions
+12 -7
View File
@@ -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 会自动出现在列表里
- 上次打开的文件夹会被记住,下次启动自动恢复
### 源代码模式
### 编辑模式 / 源代码模式
左下角状态栏的 **`<src>`** 按钮(或 `Ctrl+Shift+M`)切到源代码模式:
关掉语法高亮、行号与当前行高亮,只显示文件原始内容,适合核对格式或复制纯文本。
默认是编辑模式,按钮会显示成 `</src>` 表示当前处于源码模式。
默认是**编辑模式**:Markdown 标记不显示——标题按级别放大加粗、粗体真加粗、
行内代码带底色、链接只留文字,看到的就是排版后的内容。光标移到哪一行,
那一行会露出 `#`、`**` 这些原始标记,可以直接修改。
点左下角的 **`<src>`**(或 `Ctrl+Shift+M`)切到**源代码模式**:所有标记原样显示,
只做着色,适合核对格式或复制纯文本;按钮会变成 `</src>`。
### 文件
+4 -2
View File
@@ -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 \
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>logo.png</file>
</qresource>
</RCC>
+67 -26
View File
@@ -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()
{
// 左下角的 <src> 开关:切到源代码模式。
// 左下角:侧栏开关 + 源代码模式开关。
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("<src>"));
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)
+4
View File
@@ -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;
+31 -18
View File
@@ -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);
m_activeBlock = textCursor().blockNumber();
m_highlighter->setActiveBlock(m_activeBlock);
m_highlighter->setRendered(!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()));
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();
+2
View File
@@ -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;
};
+209 -60
View File
@@ -2,7 +2,9 @@
#include "ThemeManager.h"
#include <QTextBlock>
#include <QTextDocument>
#include <QTextLayout>
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("(?<![*\\w])\\*(?!\\*)(?=\\S)([^*\\n]+?)(?<=\\S)\\*(?!\\*)"));
applyDelimited(italicAsterisk, text, m_emphasisFormat, 1, c.editorBase);
static const QRegularExpression italicUnderscore(
QStringLiteral("(?<![_\\w])_(?!_)(?=\\S)([^_\\n]+?)(?<=\\S)_(?![\\w_])"));
applyDelimited(italicUnderscore, text, m_emphasisFormat, 1, c.editorBase);
static const QRegularExpression strikePattern(
QStringLiteral("~~(?=\\S)([^~\\n]+?)(?<=\\S)~~"));
applyDelimited(strikePattern, text, m_strikeFormat, 1, c.editorBase);
// 三重强调最后应用,压住上面的加粗 / 斜体。
static const QRegularExpression boldItalicPattern(
QStringLiteral("(\\*{3}|_{3})(?=\\S)(.+?)(?<=\\S)\\1"));
applyPattern(boldItalicPattern, text, m_strongFormat);
applyPattern(boldItalicPattern, text, m_emphasisFormat);
QStringLiteral("\\*\\*\\*(?=\\S)([^*\\n]+?)(?<=\\S)\\*\\*\\*"));
applyDelimited(boldItalicPattern, text, m_boldItalicFormat, 1, c.editorBase);
static const QRegularExpression boldPattern(QStringLiteral("(\\*\\*|__)(?=\\S)(.+?)(?<=\\S)\\1"));
applyPattern(boldPattern, text, m_strongFormat);
// ---- 行内代码(最后,压住内部的强调)---------------------------------
static const QRegularExpression codePattern(QStringLiteral("(`+)([^`\\n]+)\\1"));
auto codes = codePattern.globalMatch(text);
while (codes.hasNext()) {
const QRegularExpressionMatch match = codes.next();
const int matchStart = match.capturedStart();
const int matchEnd = matchStart + match.capturedLength();
const QTextCharFormat span = compose(m_codeFormat);
apply(matchStart, match.capturedLength(), span);
static const QRegularExpression italicPattern(
QStringLiteral("(?<![*_\\w])([*_])(?=\\S)(.+?)(?<=\\S)\\1(?![*_\\w])"));
applyPattern(italicPattern, text, m_emphasisFormat);
static const QRegularExpression strikePattern(QStringLiteral("~~(?=\\S)(.+?)(?<=\\S)~~"));
applyPattern(strikePattern, text, m_strikeFormat);
// 行内代码最后着色,压住内部的加粗 / 斜体。
static const QRegularExpression inlineCodePattern(QStringLiteral("`[^`\\n]+`"));
applyPattern(inlineCodePattern, text, m_codeFormat);
if (rendered && !active) {
const int contentStart = match.capturedStart(2);
const int contentLength = match.capturedLength(2);
if (contentLength <= 0)
continue;
QTextCharFormat hidden = span;
hidden.merge(hiddenFormat(c.codeBackground));
if (contentStart > matchStart)
apply(matchStart, contentStart - matchStart, hidden);
const int suffixStart = contentStart + contentLength;
if (matchEnd > suffixStart)
apply(suffixStart, matchEnd - suffixStart, hidden);
}
}
}
+31 -10
View File
@@ -1,14 +1,20 @@
#pragma once
#include <QColor>
#include <QRegularExpression>
#include <QSyntaxHighlighter>
#include <QTextCharFormat>
/// 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;
};
+2
View File
@@ -4,6 +4,7 @@
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QIcon>
#include <QPixmap>
#include <QStyleFactory>
#include <QTimer>
@@ -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"));