feat: v0.3.0 —— 默认编辑模式就地渲染,侧栏自动刷新
编辑模式 / 源代码模式语义修正 - 默认「编辑模式」不再显示 #、**、反引号等标记:标题按级别放大加粗、 粗体真加粗、行内代码带底色、链接只保留文字 - 光标所在行会露出原始标记,方便直接修改;切换行时只重排这两行 - 源代码模式仍是原样显示 + 着色,左下角 <src> 切换 高亮器 - QSyntaxHighlighter::setFormat 是整段替换而非合并,改为每段先与 当前行基础格式(标题 / 引用 / 代码块)compose 后再设置 - 强调正则收紧,避免 **粗体** 被 *斜体* 抢先匹配 侧栏 - 去掉刷新按钮,改为每 5 秒自动扫描;文件集合没变就不重建列表 - 状态栏左下角新增「侧栏」开关按钮,与 Ctrl+B 同步 其它 - 使用 logo.png 作为窗口图标(resources.qrc) - 版本号 0.3.0,README 同步更新
This commit is contained in:
@@ -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
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>logo.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
+67
-26
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+32
-19
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user