feat: VoiletMark 0.1.0 —— Qt5/C++ Markdown 编辑器

- 自动跟随系统的浅色/深色主题(Windows 注册表、macOS defaults、
  Linux gsettings / KDE / XFCE),每 2 秒检测,可在菜单中手动锁定
- Markdown 语法高亮(标题、强调、行内代码、围栏代码块、链接、
  引用、列表、任务列表、分隔线)
- 编辑器:行号栏、当前行高亮、Tab 缩进、拖放打开、Ctrl+滚轮缩放
- 文件:新建/打开/保存/另存为/最近打开,保留 BOM 与 CRLF,QSaveFile 原子写入
- 状态栏、未保存提示、窗口与编辑设置持久化
- CMake 构建脚本与 README
This commit is contained in:
2026-10-22 17:06:27 +08:00
commit a1016ac7d7
15 changed files with 2006 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
build/
build-*/
*.user
*.autosave
.vscode/
.idea/
compile_commands.json
CMakeLists.txt.user
+38
View File
@@ -0,0 +1,38 @@
cmake_minimum_required(VERSION 3.16)
project(VoiletMark VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
find_package(Qt5 5.12 REQUIRED COMPONENTS Core Gui Widgets)
set(VM_SOURCES
src/main.cpp
src/MainWindow.cpp
src/MarkdownEditor.cpp
src/MarkdownHighlighter.cpp
src/ThemeManager.cpp
)
set(VM_HEADERS
src/MainWindow.h
src/MarkdownEditor.h
src/MarkdownHighlighter.h
src/ThemeManager.h
)
add_executable(VoiletMark WIN32 MACOSX_BUNDLE ${VM_SOURCES} ${VM_HEADERS})
target_link_libraries(VoiletMark PRIVATE Qt5::Widgets)
target_compile_definitions(VoiletMark PRIVATE VM_VERSION="${PROJECT_VERSION}")
install(TARGETS VoiletMark
RUNTIME DESTINATION bin
BUNDLE DESTINATION .)
+102
View File
@@ -0,0 +1,102 @@
# VoiletMark
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
当前版本 **0.1.0**:先把编辑器本体做扎实——干净的界面、顺手的编辑手感、
以及**自动跟随系统**的浅色 / 深色主题。
## 界面
| 浅色 | 深色 |
| --- | --- |
| ![浅色主题](docs/screenshot-light.png) | ![深色主题](docs/screenshot-dark.png) |
## 功能
### 编辑
- Markdown 语法高亮:标题、粗体 / 斜体 / 删除线、行内代码、围栏代码块、
链接与图片、引用、有序 / 无序列表、任务列表、分隔线
- 行号栏、当前行高亮
- `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进
- 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号
- 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显
- 把 Markdown 文件直接拖进窗口即可打开
### 文件
- 新建、打开、保存、另存为、最近打开(最多 8 条)
- 保留原文件的 **BOM** 与 **CRLF** 换行风格,保存时原子写入(`QSaveFile`)
- 关闭或切换文件时提示未保存的修改
- 记忆窗口位置、字号、换行开关等设置
### 主题
界面默认**跟随系统**自动在浅色 / 深色之间切换,每 2 秒检测一次:
| 平台 | 检测方式 |
| --- | --- |
| Windows | 注册表 `AppsUseLightTheme` |
| macOS | `defaults read -g AppleInterfaceStyle` |
| Linux | gsettings `color-scheme` / GTK 主题名 → KDE `kdeglobals` → XFCE `xsettings.xml` → 调色板兜底 |
也可以在 **视图 → 主题** 中手动锁定「跟随系统 / 浅色 / 深色」,选择会被记住。
## 构建
依赖:Qt 5.12+(含 Widgets)、CMake 3.16+、支持 C++17 的编译器。
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \\
-DCMAKE_PREFIX_PATH="$HOME/Qt5/5.15.2/gcc_64"
cmake --build build -j"$(nproc)"
./build/VoiletMark
```
或者直接:
```bash
./scripts/build.sh
```
脚本会自动探测 `$HOME/Qt5/5.15.2/gcc_64`,也可以用 `QT_PREFIX` 环境变量指定。
## 命令行
```bash
VoiletMark # 打开编辑器(显示欢迎文档)
VoiletMark notes.md # 直接打开指定文件
VoiletMark --theme dark # 本次运行使用深色主题
VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉回归)
```
## 快捷键
| 快捷键 | 功能 |
| --- | --- |
| `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 |
| `Ctrl+Shift+S` | 另存为 |
| `Ctrl+Z` / `Ctrl+Shift+Z` | 撤销 / 重做 |
| `Ctrl+滚轮`、`Ctrl++`、`Ctrl+-`、`Ctrl+0` | 缩放 / 恢复字号 |
| `Alt+Z` | 切换自动换行 |
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
| `Ctrl+Q` | 退出 |
## 代码结构
```
src/
├── main.cpp 入口:命令行解析、Fusion 样式、主题初始化
├── ThemeManager.{h,cpp} 系统主题检测 + 调色板 / QSS 生成
├── MainWindow.{h,cpp} 菜单、文件读写、最近打开、状态栏、设置
├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、拖放、缩放
└── MarkdownHighlighter.{h,cpp} Markdown 语法着色(含围栏代码块状态机)
```
## 下一版计划
- 实时预览(右侧或同屏渲染)
- 查找 / 替换
- 列表按回车自动续行
- 表格与数学公式支持
- 导出 HTML / PDF
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# 一键构建。可用 QT_PREFIX 指定 Qt 前缀,例如:
# QT_PREFIX=$HOME/Qt5/5.15.2/gcc_64 ./scripts/build.sh
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -z "${QT_PREFIX:-}" ]]; then
for candidate in "$HOME/Qt5/5.15.2/gcc_64" "$HOME/Qt/5.15.2/gcc_64"; do
if [[ -d "$candidate" ]]; then
QT_PREFIX="$candidate"
break
fi
done
fi
cmake_args=(-S "$root" -B "$root/build" -DCMAKE_BUILD_TYPE=Release)
if [[ -n "${QT_PREFIX:-}" ]]; then
cmake_args+=(-DCMAKE_PREFIX_PATH="$QT_PREFIX")
echo "使用 Qt: $QT_PREFIX"
fi
cmake "${cmake_args[@]}"
cmake --build "$root/build" -j"$(nproc)"
echo
echo "构建完成: $root/build/VoiletMark"
+567
View File
@@ -0,0 +1,567 @@
#include "MainWindow.h"
#include "MarkdownEditor.h"
#include "ThemeManager.h"
#include <QAction>
#include <QActionGroup>
#include <QApplication>
#include <QCloseEvent>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QLabel>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
#include <QStatusBar>
#include <QTextBlock>
#include <QTextCursor>
#include <QTextDocument>
#include <QTimer>
namespace {
constexpr int kMaxRecentFiles = 8;
constexpr int kDefaultFontSize = 12;
const char kWelcomeText[] = R"VOILETMARK(
# 欢迎使用 VoiletMark
一款轻量的跨平台 Markdown 编辑器,基于 **Qt 5** 与 C++ 构建。
## 快速开始
- `Ctrl+O` 打开文件,`Ctrl+S` 保存,`Ctrl+Shift+S` 另存为
- 也可以直接把 Markdown 文件拖进窗口
- `Tab` / `Shift+Tab` 缩进整行,`Ctrl+滚轮` 调整字号
## 主题
界面默认跟随系统的浅色 / 深色设置自动切换,
也可以在 **视图 → 主题** 中手动锁定。
## 语法预览
> 引用、列表、`行内代码`、**粗体**、*斜体*、~~删除线~~
> 以及 [链接](https://example.com) 都会实时着色。
1. 有序列表项
2. 另一个有序列表项
- [x] 自动深浅色主题
- [x] Markdown 语法高亮
- [ ] 实时预览(下一版)
```cpp
#include <cstdio>
int main()
{
std::printf("Hello, VoiletMark!\n");
return 0;
}
```
---
)VOILETMARK";
} // namespace
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
setMinimumSize(760, 520);
resize(1024, 720);
m_editor = new MarkdownEditor(this);
setCentralWidget(m_editor);
createActions();
createMenus();
createStatusBar();
loadSettings();
refreshRecentMenu();
connect(m_editor->document(), &QTextDocument::modificationChanged,
this, &MainWindow::updateWindowTitle);
connect(m_editor, &QPlainTextEdit::cursorPositionChanged,
this, &MainWindow::updateStatusBar);
connect(m_editor, &MarkdownEditor::zoomStepRequested,
this, &MainWindow::changeZoom);
connect(m_editor, &MarkdownEditor::fileDropped,
this, &MainWindow::openPath);
showWelcomeDocument();
updateEditActionStates();
updateStatusBar();
m_editor->setFocus();
}
void MainWindow::createActions()
{
m_newAction = new QAction(tr("新建"), this);
m_newAction->setShortcut(QKeySequence::New);
connect(m_newAction, &QAction::triggered, this, &MainWindow::newFile);
m_openAction = new QAction(tr("打开…"), this);
m_openAction->setShortcut(QKeySequence::Open);
connect(m_openAction, &QAction::triggered, this, &MainWindow::openFile);
m_saveAction = new QAction(tr("保存"), this);
m_saveAction->setShortcut(QKeySequence::Save);
connect(m_saveAction, &QAction::triggered, this, [this] { saveFile(); });
m_saveAsAction = new QAction(tr("另存为…"), this);
m_saveAsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+S")));
connect(m_saveAsAction, &QAction::triggered, this, [this] { saveFileAs(); });
m_quitAction = new QAction(tr("退出"), this);
m_quitAction->setShortcut(QKeySequence::Quit);
connect(m_quitAction, &QAction::triggered, this, &QWidget::close);
m_undoAction = new QAction(tr("撤销"), this);
m_undoAction->setShortcut(QKeySequence::Undo);
connect(m_undoAction, &QAction::triggered, m_editor, &QPlainTextEdit::undo);
m_redoAction = new QAction(tr("重做"), this);
m_redoAction->setShortcut(QKeySequence::Redo);
connect(m_redoAction, &QAction::triggered, m_editor, &QPlainTextEdit::redo);
m_cutAction = new QAction(tr("剪切"), this);
m_cutAction->setShortcut(QKeySequence::Cut);
connect(m_cutAction, &QAction::triggered, m_editor, &QPlainTextEdit::cut);
m_copyAction = new QAction(tr("复制"), this);
m_copyAction->setShortcut(QKeySequence::Copy);
connect(m_copyAction, &QAction::triggered, m_editor, &QPlainTextEdit::copy);
m_pasteAction = new QAction(tr("粘贴"), this);
m_pasteAction->setShortcut(QKeySequence::Paste);
connect(m_pasteAction, &QAction::triggered, m_editor, &QPlainTextEdit::paste);
m_selectAllAction = new QAction(tr("全选"), this);
m_selectAllAction->setShortcut(QKeySequence::SelectAll);
connect(m_selectAllAction, &QAction::triggered, m_editor, &QPlainTextEdit::selectAll);
m_wrapAction = new QAction(tr("自动换行"), this);
m_wrapAction->setCheckable(true);
m_wrapAction->setShortcut(QKeySequence(QStringLiteral("Alt+Z")));
connect(m_wrapAction, &QAction::toggled, this, &MainWindow::setWordWrapEnabled);
m_zoomInAction = new QAction(tr("放大"), this);
m_zoomInAction->setShortcut(QKeySequence::ZoomIn);
connect(m_zoomInAction, &QAction::triggered, this, [this] { changeZoom(1); });
m_zoomOutAction = new QAction(tr("缩小"), this);
m_zoomOutAction->setShortcut(QKeySequence::ZoomOut);
connect(m_zoomOutAction, &QAction::triggered, this, [this] { changeZoom(-1); });
m_zoomResetAction = new QAction(tr("实际大小"), this);
m_zoomResetAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0")));
connect(m_zoomResetAction, &QAction::triggered, this, &MainWindow::resetZoom);
connect(m_editor, &QPlainTextEdit::undoAvailable, m_undoAction, &QAction::setEnabled);
connect(m_editor, &QPlainTextEdit::redoAvailable, m_redoAction, &QAction::setEnabled);
connect(m_editor, &QPlainTextEdit::copyAvailable, m_cutAction, &QAction::setEnabled);
connect(m_editor, &QPlainTextEdit::copyAvailable, m_copyAction, &QAction::setEnabled);
}
void MainWindow::createMenus()
{
QMenu *fileMenu = menuBar()->addMenu(tr("文件"));
fileMenu->addAction(m_newAction);
fileMenu->addAction(m_openAction);
m_recentMenu = fileMenu->addMenu(tr("最近打开"));
fileMenu->addSeparator();
fileMenu->addAction(m_saveAction);
fileMenu->addAction(m_saveAsAction);
fileMenu->addSeparator();
fileMenu->addAction(m_quitAction);
QMenu *editMenu = menuBar()->addMenu(tr("编辑"));
editMenu->addAction(m_undoAction);
editMenu->addAction(m_redoAction);
editMenu->addSeparator();
editMenu->addAction(m_cutAction);
editMenu->addAction(m_copyAction);
editMenu->addAction(m_pasteAction);
editMenu->addSeparator();
editMenu->addAction(m_selectAllAction);
QMenu *viewMenu = menuBar()->addMenu(tr("视图"));
viewMenu->addAction(m_wrapAction);
viewMenu->addSeparator();
viewMenu->addAction(m_zoomInAction);
viewMenu->addAction(m_zoomOutAction);
viewMenu->addAction(m_zoomResetAction);
viewMenu->addSeparator();
QMenu *themeMenu = viewMenu->addMenu(tr("主题"));
QActionGroup *themeGroup = new QActionGroup(this);
themeGroup->setExclusive(true);
m_themeSystemAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::System), this);
m_themeLightAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Light), this);
m_themeDarkAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Dark), this);
for (QAction *action : {m_themeSystemAction, m_themeLightAction, m_themeDarkAction}) {
action->setCheckable(true);
themeGroup->addAction(action);
themeMenu->addAction(action);
}
m_themeSystemAction->setChecked(true);
connect(m_themeSystemAction, &QAction::triggered, this,
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::System)); });
connect(m_themeLightAction, &QAction::triggered, this,
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Light)); });
connect(m_themeDarkAction, &QAction::triggered, this,
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Dark)); });
QMenu *helpMenu = menuBar()->addMenu(tr("帮助"));
helpMenu->addAction(tr("关于 VoiletMark"), this, &MainWindow::showAbout);
}
void MainWindow::createStatusBar()
{
m_statusLabel = new QLabel(this);
m_statusLabel->setContentsMargins(8, 0, 10, 0);
statusBar()->addPermanentWidget(m_statusLabel);
statusBar()->setSizeGripEnabled(false);
}
void MainWindow::loadSettings()
{
QSettings settings;
const QByteArray geometry = settings.value(QStringLiteral("window/geometry")).toByteArray();
if (!geometry.isEmpty())
restoreGeometry(geometry);
m_lastDirectory = settings.value(QStringLiteral("files/lastDirectory")).toString();
if (m_lastDirectory.isEmpty())
m_lastDirectory = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
const int fontSize = settings.value(QStringLiteral("editor/fontSize"), kDefaultFontSize).toInt();
m_editor->setEditorFontPointSize(fontSize);
const bool wrap = settings.value(QStringLiteral("editor/wordWrap"), true).toBool();
m_wrapAction->setChecked(wrap);
setWordWrapEnabled(wrap);
loadRecentFiles();
const ThemeManager::Mode mode = ThemeManager::instance()->mode();
m_themeSystemAction->setChecked(mode == ThemeManager::Mode::System);
m_themeLightAction->setChecked(mode == ThemeManager::Mode::Light);
m_themeDarkAction->setChecked(mode == ThemeManager::Mode::Dark);
}
void MainWindow::saveSettings()
{
QSettings settings;
settings.setValue(QStringLiteral("window/geometry"), saveGeometry());
settings.setValue(QStringLiteral("editor/fontSize"), m_editor->editorFontPointSize());
settings.setValue(QStringLiteral("editor/wordWrap"), m_wrapAction->isChecked());
settings.setValue(QStringLiteral("files/lastDirectory"), m_lastDirectory);
settings.setValue(QStringLiteral("files/recent"), m_recentFiles);
}
void MainWindow::closeEvent(QCloseEvent *event)
{
if (!maybeSave()) {
event->ignore();
return;
}
saveSettings();
event->accept();
}
QString MainWindow::displayName() const
{
if (m_currentPath.isEmpty())
return tr("未命名");
return QFileInfo(m_currentPath).fileName();
}
QString MainWindow::startDirectory() const
{
if (!m_currentPath.isEmpty())
return QFileInfo(m_currentPath).absolutePath();
if (QFileInfo::exists(m_lastDirectory))
return m_lastDirectory;
return QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
}
void MainWindow::showWelcomeDocument()
{
QString text = QString::fromUtf8(kWelcomeText);
if (text.startsWith(QLatin1Char('\n')))
text.remove(0, 1);
m_editor->setPlainText(text);
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
setCurrentFile(QString());
m_editor->moveCursor(QTextCursor::Start);
}
void MainWindow::newFile()
{
if (!maybeSave())
return;
m_editor->clear();
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
m_hasBom = false;
m_eol = Eol::Lf;
setCurrentFile(QString());
updateStatusBar();
m_editor->setFocus();
}
void MainWindow::openFile()
{
if (!maybeSave())
return;
const QString path = QFileDialog::getOpenFileName(
this, tr("打开 Markdown 文件"), startDirectory(),
tr("Markdown 文件 (*.md *.markdown *.mdown *.mkd);;文本文件 (*.txt);;所有文件 (*)"));
if (path.isEmpty())
return;
openPath(path);
}
bool MainWindow::openPath(const QString &path)
{
if (path.isEmpty())
return false;
const QString absolutePath = QFileInfo(path).absoluteFilePath();
if (!maybeSave())
return false;
QFile file(absolutePath);
if (!file.open(QIODevice::ReadOnly)) {
QMessageBox::warning(this, tr("无法打开文件"),
tr("读取「%1」失败:%2")
.arg(QDir::toNativeSeparators(absolutePath), file.errorString()));
return false;
}
const QByteArray raw = file.readAll();
file.close();
m_hasBom = raw.startsWith("\xEF\xBB\xBF");
QString text = QString::fromUtf8(m_hasBom ? raw.mid(3) : raw);
m_eol = text.contains(QStringLiteral("\r\n")) ? Eol::Crlf : Eol::Lf;
if (m_eol == Eol::Crlf)
text.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
m_editor->setPlainText(text);
m_editor->document()->setModified(false);
m_editor->document()->clearUndoRedoStacks();
setCurrentFile(absolutePath);
addRecentFile(absolutePath);
updateStatusBar();
m_editor->setFocus();
return true;
}
bool MainWindow::saveFile()
{
if (m_currentPath.isEmpty())
return saveFileAs();
return writeToFile(m_currentPath);
}
bool MainWindow::saveFileAs()
{
QString path = QFileDialog::getSaveFileName(
this, tr("另存为"), startDirectory(),
tr("Markdown 文件 (*.md);;文本文件 (*.txt);;所有文件 (*)"));
if (path.isEmpty())
return false;
if (QFileInfo(path).suffix().isEmpty())
path += QStringLiteral(".md");
return writeToFile(path);
}
bool MainWindow::writeToFile(const QString &path)
{
QString text = m_editor->toPlainText();
if (m_eol == Eol::Crlf)
text.replace(QLatin1Char('\n'), QStringLiteral("\r\n"));
QByteArray data = text.toUtf8();
if (m_hasBom)
data.prepend("\xEF\xBB\xBF");
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
QMessageBox::warning(this, tr("无法保存文件"),
tr("写入「%1」失败:%2")
.arg(QDir::toNativeSeparators(path), file.errorString()));
return false;
}
if (file.write(data) != data.size()) {
file.cancelWriting();
QMessageBox::warning(this, tr("无法保存文件"),
tr("写入「%1」时发生错误。").arg(QDir::toNativeSeparators(path)));
return false;
}
if (!file.commit()) {
QMessageBox::warning(this, tr("无法保存文件"),
tr("提交「%1」失败:%2")
.arg(QDir::toNativeSeparators(path), file.errorString()));
return false;
}
m_editor->document()->setModified(false);
setCurrentFile(path);
addRecentFile(path);
statusBar()->showMessage(tr("已保存 %1").arg(QDir::toNativeSeparators(path)), 2500);
return true;
}
bool MainWindow::maybeSave()
{
if (!m_editor->document()->isModified())
return true;
const QMessageBox::StandardButton button = QMessageBox::warning(
this, tr("VoiletMark"),
tr("「%1」有未保存的修改,是否保存?").arg(displayName()),
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel,
QMessageBox::Save);
if (button == QMessageBox::Save)
return saveFile();
return button == QMessageBox::Discard;
}
void MainWindow::setCurrentFile(const QString &path)
{
m_currentPath = path;
if (!path.isEmpty()) {
m_lastDirectory = QFileInfo(path).absolutePath();
setWindowFilePath(path);
} else {
setWindowFilePath(QString());
}
updateWindowTitle();
}
void MainWindow::updateWindowTitle()
{
setWindowTitle(tr("%1[*] — VoiletMark").arg(displayName()));
setWindowModified(m_editor->document()->isModified());
}
void MainWindow::updateEditActionStates()
{
m_undoAction->setEnabled(m_editor->document()->isUndoAvailable());
m_redoAction->setEnabled(m_editor->document()->isRedoAvailable());
m_cutAction->setEnabled(m_editor->textCursor().hasSelection());
m_copyAction->setEnabled(m_editor->textCursor().hasSelection());
}
void MainWindow::updateStatusBar()
{
const QTextCursor cursor = m_editor->textCursor();
const int line = cursor.blockNumber() + 1;
const int column = cursor.positionInBlock() + 1;
const int characters = m_editor->document()->characterCount() - 1;
QString text = tr("行 %1,列 %2").arg(line).arg(column);
if (cursor.hasSelection())
text += tr(" 已选 %1 字").arg(cursor.selectedText().size());
text += tr(" %1 字").arg(qMax(0, characters));
text += QStringLiteral(" UTF-8");
m_statusLabel->setText(text);
}
void MainWindow::changeZoom(int direction)
{
const int size = (direction == 0) ? kDefaultFontSize
: m_editor->editorFontPointSize() + direction;
m_editor->setEditorFontPointSize(size);
QSettings().setValue(QStringLiteral("editor/fontSize"), m_editor->editorFontPointSize());
}
void MainWindow::resetZoom()
{
changeZoom(0);
}
void MainWindow::setWordWrapEnabled(bool enabled)
{
m_editor->setLineWrapMode(enabled ? QPlainTextEdit::WidgetWidth : QPlainTextEdit::NoWrap);
QSettings().setValue(QStringLiteral("editor/wordWrap"), enabled);
}
void MainWindow::applyThemeMode(int mode)
{
ThemeManager::instance()->setMode(static_cast<ThemeManager::Mode>(mode), true);
}
void MainWindow::showAbout()
{
QMessageBox::about(
this, tr("关于 VoiletMark"),
tr("<h3>VoiletMark %1</h3>"
"<p>一个轻量的跨平台 Markdown 编辑器。</p>"
"<p>基于 Qt %2 与 C++17 构建,界面会跟随系统自动切换浅色 / 深色主题。</p>")
.arg(QCoreApplication::applicationVersion(), QString::fromLatin1(qVersion())));
}
void MainWindow::addRecentFile(const QString &path)
{
const QString absolutePath = QFileInfo(path).absoluteFilePath();
m_recentFiles.removeAll(absolutePath);
m_recentFiles.prepend(absolutePath);
while (m_recentFiles.size() > kMaxRecentFiles)
m_recentFiles.removeLast();
refreshRecentMenu();
QSettings().setValue(QStringLiteral("files/recent"), m_recentFiles);
}
void MainWindow::loadRecentFiles()
{
m_recentFiles = QSettings().value(QStringLiteral("files/recent")).toStringList();
m_recentFiles.removeDuplicates();
while (m_recentFiles.size() > kMaxRecentFiles)
m_recentFiles.removeLast();
}
void MainWindow::refreshRecentMenu()
{
if (!m_recentMenu)
return;
m_recentMenu->clear();
if (m_recentFiles.isEmpty()) {
QAction *empty = m_recentMenu->addAction(tr("(暂无记录)"));
empty->setEnabled(false);
return;
}
for (const QString &path : m_recentFiles) {
QAction *action = m_recentMenu->addAction(QDir::toNativeSeparators(path));
action->setToolTip(path);
connect(action, &QAction::triggered, this, [this, path] {
openPath(path);
});
}
m_recentMenu->addSeparator();
m_recentMenu->addAction(tr("清空列表"), this, [this] {
m_recentFiles.clear();
QSettings().setValue(QStringLiteral("files/recent"), m_recentFiles);
refreshRecentMenu();
});
}
+92
View File
@@ -0,0 +1,92 @@
#pragma once
#include <QMainWindow>
#include <QStringList>
class QAction;
class QActionGroup;
class QLabel;
class QMenu;
class QTextDocument;
class MarkdownEditor;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
bool openPath(const QString &path);
protected:
void closeEvent(QCloseEvent *event) override;
private:
enum class Eol { Lf, Crlf };
void createActions();
void createMenus();
void createStatusBar();
void loadSettings();
void saveSettings();
void newFile();
void openFile();
bool saveFile();
bool saveFileAs();
bool maybeSave();
bool writeToFile(const QString &path);
void setCurrentFile(const QString &path);
void showWelcomeDocument();
void addRecentFile(const QString &path);
void refreshRecentMenu();
void loadRecentFiles();
void changeZoom(int direction);
void resetZoom();
void setWordWrapEnabled(bool enabled);
void applyThemeMode(int mode);
void showAbout();
void updateStatusBar();
void updateWindowTitle();
void updateEditActionStates();
QString displayName() const;
QString startDirectory() const;
MarkdownEditor *m_editor = nullptr;
QLabel *m_statusLabel = nullptr;
QString m_currentPath;
Eol m_eol = Eol::Lf;
bool m_hasBom = false;
QStringList m_recentFiles;
QString m_lastDirectory;
QAction *m_newAction = nullptr;
QAction *m_openAction = nullptr;
QAction *m_saveAction = nullptr;
QAction *m_saveAsAction = nullptr;
QAction *m_quitAction = nullptr;
QAction *m_undoAction = nullptr;
QAction *m_redoAction = nullptr;
QAction *m_cutAction = nullptr;
QAction *m_copyAction = nullptr;
QAction *m_pasteAction = nullptr;
QAction *m_selectAllAction = nullptr;
QAction *m_wrapAction = nullptr;
QAction *m_zoomInAction = nullptr;
QAction *m_zoomOutAction = nullptr;
QAction *m_zoomResetAction = nullptr;
QAction *m_themeSystemAction = nullptr;
QAction *m_themeLightAction = nullptr;
QAction *m_themeDarkAction = nullptr;
QMenu *m_recentMenu = nullptr;
};
+314
View File
@@ -0,0 +1,314 @@
#include "MarkdownEditor.h"
#include "MarkdownHighlighter.h"
#include "ThemeManager.h"
#include <QAbstractTextDocumentLayout>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFileInfo>
#include <QFontDatabase>
#include <QFontMetricsF>
#include <QKeyEvent>
#include <QMimeData>
#include <QPainter>
#include <QTextBlock>
#include <QTextOption>
#include <QUrl>
#include <QWheelEvent>
namespace {
constexpr int kLineNumberRightPadding = 14;
class LineNumberArea : public QWidget
{
public:
explicit LineNumberArea(MarkdownEditor *editor)
: QWidget(editor)
, m_editor(editor)
{
setAttribute(Qt::WA_OpaquePaintEvent, true);
}
QSize sizeHint() const override
{
return QSize(m_editor->lineNumberAreaWidth(), 0);
}
protected:
void paintEvent(QPaintEvent *event) override
{
m_editor->lineNumberAreaPaintEvent(event);
}
private:
MarkdownEditor *m_editor;
};
} // namespace
MarkdownEditor::MarkdownEditor(QWidget *parent)
: QPlainTextEdit(parent)
{
setObjectName(QStringLiteral("markdownEditor"));
setFrameShape(QFrame::NoFrame);
setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
setLineWrapMode(QPlainTextEdit::WidgetWidth);
setCursorWidth(2);
setAcceptDrops(true);
document()->setDocumentMargin(18);
m_lineNumberArea = new LineNumberArea(this);
m_highlighter = new MarkdownHighlighter(document());
connect(this, &QPlainTextEdit::blockCountChanged,
this, &MarkdownEditor::updateLineNumberAreaWidth);
connect(this, &QPlainTextEdit::updateRequest,
this, &MarkdownEditor::updateLineNumberArea);
connect(this, &QPlainTextEdit::cursorPositionChanged,
this, &MarkdownEditor::updateCurrentLineHighlight);
connect(ThemeManager::instance(), &ThemeManager::schemeChanged,
this, &MarkdownEditor::applyTheme);
applyEditorFont();
updateLineNumberAreaWidth(0);
updateCurrentLineHighlight();
}
int MarkdownEditor::lineNumberAreaWidth() const
{
int digits = 1;
int blocks = qMax(1, blockCount());
while (blocks >= 10) {
blocks /= 10;
++digits;
}
return 18 + fontMetrics().horizontalAdvance(QLatin1Char('9')) * digits;
}
void MarkdownEditor::lineNumberAreaPaintEvent(QPaintEvent *event)
{
QPainter painter(m_lineNumberArea);
const ThemeColors &colors = ThemeManager::instance()->colors();
painter.fillRect(event->rect(), colors.gutterBackground);
QTextBlock block = firstVisibleBlock();
int blockNumber = block.blockNumber();
int top = qRound(blockBoundingGeometry(block).translated(contentOffset()).top());
int bottom = top + qRound(blockBoundingRect(block).height());
const int currentLine = textCursor().blockNumber();
QFont numberFont = font();
if (numberFont.pointSizeF() > 8.5)
numberFont.setPointSizeF(numberFont.pointSizeF() - 1.5);
painter.setFont(numberFont);
const QFontMetrics metrics(numberFont);
while (block.isValid() && top <= event->rect().bottom()) {
if (block.isVisible() && bottom >= event->rect().top()) {
painter.setPen(blockNumber == currentLine ? colors.gutterActiveText : colors.gutterText);
painter.drawText(0, top, m_lineNumberArea->width() - kLineNumberRightPadding,
metrics.height(), Qt::AlignRight | Qt::AlignVCenter,
QString::number(blockNumber + 1));
}
block = block.next();
top = bottom;
bottom = top + qRound(blockBoundingRect(block).height());
++blockNumber;
}
}
void MarkdownEditor::setEditorFontPointSize(int points)
{
const int clamped = qBound(8, points, 48);
if (clamped == m_fontPointSize)
return;
m_fontPointSize = clamped;
applyEditorFont();
}
void MarkdownEditor::applyEditorFont()
{
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
font.setFamilies({QStringLiteral("JetBrains Mono"),
QStringLiteral("Cascadia Code"),
QStringLiteral("Source Han Mono SC"),
QStringLiteral("Noto Sans Mono CJK SC"),
QStringLiteral("Sarasa Mono SC"),
QStringLiteral("Consolas"),
QStringLiteral("DejaVu Sans Mono")});
font.setPointSize(m_fontPointSize);
font.setStyleHint(QFont::Monospace);
font.setFixedPitch(true);
setFont(font);
document()->setDefaultFont(font);
setTabStopDistance(QFontMetricsF(font).horizontalAdvance(QLatin1Char(' ')) * 4.0);
updateLineNumberAreaWidth(0);
}
void MarkdownEditor::updateLineNumberAreaWidth(int)
{
setViewportMargins(lineNumberAreaWidth(), 0, 0, 0);
}
void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy)
{
if (dy != 0)
m_lineNumberArea->scroll(0, dy);
else
m_lineNumberArea->update(0, rect.y(), m_lineNumberArea->width(), rect.height());
if (rect.contains(viewport()->rect()))
updateLineNumberAreaWidth(0);
}
void MarkdownEditor::updateCurrentLineHighlight()
{
QTextEdit::ExtraSelection selection;
selection.format.setBackground(ThemeManager::instance()->colors().currentLine);
selection.format.setProperty(QTextFormat::FullWidthSelection, true);
selection.cursor = textCursor();
selection.cursor.clearSelection();
setExtraSelections({selection});
}
void MarkdownEditor::applyTheme()
{
updateCurrentLineHighlight();
m_lineNumberArea->update();
viewport()->update();
}
void MarkdownEditor::resizeEvent(QResizeEvent *event)
{
QPlainTextEdit::resizeEvent(event);
const QRect rect = contentsRect();
m_lineNumberArea->setGeometry(QRect(rect.left(), rect.top(),
lineNumberAreaWidth(), rect.height()));
}
void MarkdownEditor::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Tab) {
if (textCursor().hasSelection())
indentSelection(false);
else
insertPlainText(QStringLiteral(" "));
return;
}
if (event->key() == Qt::Key_Backtab) {
indentSelection(true);
return;
}
QPlainTextEdit::keyPressEvent(event);
}
void MarkdownEditor::indentSelection(bool outdent)
{
QTextCursor cursor = textCursor();
const int anchor = cursor.hasSelection() ? cursor.selectionStart() : cursor.position();
const int position = cursor.hasSelection() ? cursor.selectionEnd() : cursor.position();
QTextCursor startCursor(document());
startCursor.setPosition(anchor);
QTextCursor endCursor(document());
endCursor.setPosition(position);
const int firstBlock = startCursor.blockNumber();
const int lastBlock = endCursor.blockNumber();
const bool hadSelection = cursor.hasSelection();
cursor.beginEditBlock();
for (int index = firstBlock; index <= lastBlock; ++index) {
QTextBlock block = document()->findBlockByNumber(index);
QTextCursor blockCursor(block);
if (outdent) {
const QString text = block.text();
int removable = 0;
while (removable < 4 && removable < text.size()
&& text.at(removable) == QLatin1Char(' ')) {
++removable;
}
if (removable > 0) {
blockCursor.setPosition(block.position());
blockCursor.setPosition(block.position() + removable, QTextCursor::KeepAnchor);
blockCursor.removeSelectedText();
}
} else {
blockCursor.setPosition(block.position());
blockCursor.insertText(QStringLiteral(" "));
}
}
cursor.endEditBlock();
// 还原选区(行宽变化后按块首尾重新计算)。
if (hadSelection) {
QTextCursor from(document());
from.setPosition(document()->findBlockByNumber(firstBlock).position());
QTextCursor to(document());
const QTextBlock last = document()->findBlockByNumber(lastBlock);
to.setPosition(last.position() + last.length() - 1);
from.setPosition(to.position(), QTextCursor::KeepAnchor);
setTextCursor(from);
}
}
void MarkdownEditor::wheelEvent(QWheelEvent *event)
{
if (event->modifiers().testFlag(Qt::ControlModifier)) {
const int delta = event->angleDelta().y();
if (delta != 0) {
emit zoomStepRequested(delta > 0 ? 1 : -1);
event->accept();
return;
}
}
QPlainTextEdit::wheelEvent(event);
}
bool MarkdownEditor::acceptsLocalFiles(const QMimeData *mime) const
{
if (!mime->hasUrls())
return false;
const QList<QUrl> urls = mime->urls();
for (const QUrl &url : urls) {
if (url.isLocalFile() && QFileInfo(url.toLocalFile()).isFile())
return true;
}
return false;
}
void MarkdownEditor::dragEnterEvent(QDragEnterEvent *event)
{
if (acceptsLocalFiles(event->mimeData())) {
event->acceptProposedAction();
return;
}
QPlainTextEdit::dragEnterEvent(event);
}
void MarkdownEditor::dragMoveEvent(QDragMoveEvent *event)
{
if (acceptsLocalFiles(event->mimeData())) {
event->acceptProposedAction();
return;
}
QPlainTextEdit::dragMoveEvent(event);
}
void MarkdownEditor::dropEvent(QDropEvent *event)
{
if (event->mimeData()->hasUrls()) {
const QList<QUrl> urls = event->mimeData()->urls();
for (const QUrl &url : urls) {
if (url.isLocalFile() && QFileInfo(url.toLocalFile()).isFile()) {
event->acceptProposedAction();
emit fileDropped(url.toLocalFile());
return;
}
}
}
QPlainTextEdit::dropEvent(event);
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <QPlainTextEdit>
class MarkdownHighlighter;
/// 编辑器本体:等宽字体、行号栏、当前行高亮、Tab 缩进、
/// Ctrl+滚轮缩放,以及把拖入的文件转交给主窗口打开。
class MarkdownEditor : public QPlainTextEdit
{
Q_OBJECT
public:
explicit MarkdownEditor(QWidget *parent = nullptr);
int lineNumberAreaWidth() const;
void lineNumberAreaPaintEvent(QPaintEvent *event);
void setEditorFontPointSize(int points);
int editorFontPointSize() const { return m_fontPointSize; }
MarkdownHighlighter *highlighter() const { return m_highlighter; }
signals:
void zoomStepRequested(int direction);
void fileDropped(const QString &path);
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private slots:
void updateLineNumberAreaWidth(int newBlockCount);
void updateLineNumberArea(const QRect &rect, int dy);
void updateCurrentLineHighlight();
void applyTheme();
private:
void applyEditorFont();
void indentSelection(bool outdent);
bool acceptsLocalFiles(const QMimeData *mime) const;
QWidget *m_lineNumberArea = nullptr;
MarkdownHighlighter *m_highlighter = nullptr;
int m_fontPointSize = 12;
};
+190
View File
@@ -0,0 +1,190 @@
#include "MarkdownHighlighter.h"
#include "ThemeManager.h"
#include <QTextDocument>
namespace {
constexpr int StateNormal = 0;
constexpr int StateInFence = 1;
} // namespace
MarkdownHighlighter::MarkdownHighlighter(QTextDocument *document)
: QSyntaxHighlighter(document)
{
rebuildFormats();
connect(ThemeManager::instance(), &ThemeManager::schemeChanged, this, [this] {
rebuildFormats();
rehighlight();
});
}
void MarkdownHighlighter::rebuildFormats()
{
const ThemeColors &c = ThemeManager::instance()->colors();
m_headingFormat = QTextCharFormat();
m_headingFormat.setForeground(c.heading);
m_headingFormat.setFontWeight(QFont::DemiBold);
m_headingMarkerFormat = QTextCharFormat();
m_headingMarkerFormat.setForeground(c.headingMarker);
m_strongFormat = QTextCharFormat();
m_strongFormat.setForeground(c.strong);
m_strongFormat.setFontWeight(QFont::Bold);
m_emphasisFormat = QTextCharFormat();
m_emphasisFormat.setForeground(c.emphasis);
m_emphasisFormat.setFontItalic(true);
m_codeFormat = QTextCharFormat();
m_codeFormat.setForeground(c.code);
m_codeFormat.setBackground(c.codeBackground);
m_codeFormat.setFontWeight(QFont::Normal);
m_codeFormat.setFontItalic(false);
m_linkFormat = QTextCharFormat();
m_linkFormat.setForeground(c.link);
m_urlFormat = QTextCharFormat();
m_urlFormat.setForeground(c.linkUrl);
m_quoteFormat = QTextCharFormat();
m_quoteFormat.setForeground(c.quote);
m_quoteFormat.setFontItalic(true);
m_quoteMarkerFormat = QTextCharFormat();
m_quoteMarkerFormat.setForeground(c.quoteMarker);
m_quoteMarkerFormat.setFontItalic(false);
m_listMarkerFormat = QTextCharFormat();
m_listMarkerFormat.setForeground(c.listMarker);
m_listMarkerFormat.setFontWeight(QFont::DemiBold);
m_ruleFormat = QTextCharFormat();
m_ruleFormat.setForeground(c.rule);
m_taskDoneFormat = QTextCharFormat();
m_taskDoneFormat.setForeground(c.taskDone);
m_taskDoneFormat.setFontWeight(QFont::DemiBold);
m_strikeFormat = QTextCharFormat();
m_strikeFormat.setFontStrikeOut(true);
m_strikeFormat.setForeground(c.quote);
}
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)
{
auto it = pattern.globalMatch(text);
while (it.hasNext()) {
const QRegularExpressionMatch match = it.next();
apply(match.capturedStart(), match.capturedLength(), format);
}
}
void MarkdownHighlighter::highlightBlock(const QString &text)
{
setCurrentBlockState(StateNormal);
if (text.isEmpty())
return;
static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)"));
// ---- 围栏代码块:跨行状态机 ----------------------------------------
if (previousBlockState() == StateInFence) {
apply(0, text.length(), m_codeFormat);
if (!fencePattern.match(text).hasMatch())
setCurrentBlockState(StateInFence);
return;
}
if (fencePattern.match(text).hasMatch()) {
apply(0, text.length(), m_codeFormat);
setCurrentBlockState(StateInFence);
return;
}
// ---- 块级语法 ------------------------------------------------------
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);
}
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);
}
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);
} 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);
if (task.capturedLength(1) > 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);
}
} 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);
}
}
// ---- 行内语法 ------------------------------------------------------
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);
}
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);
}
static const QRegularExpression boldItalicPattern(
QStringLiteral("(\\*{3}|_{3})(?=\\S)(.+?)(?<=\\S)\\1"));
applyPattern(boldItalicPattern, text, m_strongFormat);
applyPattern(boldItalicPattern, text, m_emphasisFormat);
static const QRegularExpression boldPattern(QStringLiteral("(\\*\\*|__)(?=\\S)(.+?)(?<=\\S)\\1"));
applyPattern(boldPattern, text, m_strongFormat);
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);
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <QRegularExpression>
#include <QSyntaxHighlighter>
#include <QTextCharFormat>
/// Markdown 语法着色。
///
/// 只做“逐行着色”,不改动文档内容:标题、引用、列表、行内样式、
/// 链接以及跨行的围栏代码块。颜色全部取自 ThemeManager,
/// 主题切换后自动重建格式并重新着色。
class MarkdownHighlighter : public QSyntaxHighlighter
{
Q_OBJECT
public:
explicit MarkdownHighlighter(QTextDocument *document = nullptr);
protected:
void highlightBlock(const QString &text) override;
private:
void rebuildFormats();
void apply(int start, int length, const QTextCharFormat &format);
void applyPattern(const QRegularExpression &pattern, const QString &text,
const QTextCharFormat &format);
QTextCharFormat m_headingFormat;
QTextCharFormat m_headingMarkerFormat;
QTextCharFormat m_strongFormat;
QTextCharFormat m_emphasisFormat;
QTextCharFormat m_codeFormat;
QTextCharFormat m_linkFormat;
QTextCharFormat m_urlFormat;
QTextCharFormat m_quoteFormat;
QTextCharFormat m_quoteMarkerFormat;
QTextCharFormat m_listMarkerFormat;
QTextCharFormat m_ruleFormat;
QTextCharFormat m_taskDoneFormat;
QTextCharFormat m_strikeFormat;
};
+397
View File
@@ -0,0 +1,397 @@
#include "ThemeManager.h"
#include <QApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QGuiApplication>
#include <QPalette>
#include <QProcess>
#include <QSettings>
#include <QStandardPaths>
#include <QTimer>
#include <QXmlStreamReader>
namespace {
QString runCommand(const QString &program, const QStringList &arguments, int timeoutMs)
{
QProcess process;
process.start(program, arguments);
if (!process.waitForFinished(timeoutMs)) {
process.kill();
process.waitForFinished(200);
return QString();
}
if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0)
return QString();
return QString::fromUtf8(process.readAllStandardOutput()).trimmed();
}
bool looksDark(const QString &themeName)
{
return themeName.contains(QLatin1String("dark"), Qt::CaseInsensitive);
}
/// XFCE 把界面主题记录在 xfconf 的 xsettings.xml 里。
QString xfceThemeName()
{
QFile file(QDir::homePath() + QStringLiteral("/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml"));
if (!file.open(QIODevice::ReadOnly))
return QString();
QXmlStreamReader xml(&file);
while (!xml.atEnd()) {
xml.readNext();
if (!xml.isStartElement() || xml.name().toString() != QLatin1String("property"))
continue;
const QXmlStreamAttributes attributes = xml.attributes();
if (attributes.value(QLatin1String("name")).toString() == QLatin1String("ThemeName"))
return attributes.value(QLatin1String("value")).toString();
}
return QString();
}
QColor hex(const char *value)
{
return QColor(QString::fromLatin1(value));
}
ThemeColors lightColors()
{
ThemeColors c;
c.window = hex("#f2f3f5");
c.windowText = hex("#1f2328");
c.base = hex("#ffffff");
c.alternateBase = hex("#f6f7f9");
c.text = hex("#1f2328");
c.button = hex("#ffffff");
c.buttonText = hex("#1f2328");
c.border = hex("#dcdfe4");
c.accent = hex("#0969da");
c.highlight = hex("#cfe3ff");
c.highlightedText = hex("#0b1f38");
c.disabled = hex("#9aa2ab");
c.placeholder = hex("#6b7480");
c.menuBackground = hex("#ffffff");
c.scrollHandle = hex("#c9ced6");
c.scrollHandleHover = hex("#aeb5bf");
c.editorBase = hex("#ffffff");
c.editorText = hex("#24292f");
c.gutterBackground = hex("#fafbfc");
c.gutterText = hex("#b0b7c0");
c.gutterActiveText = hex("#5c6670");
c.currentLine = hex("#f4f6f9");
c.heading = hex("#0a52a3");
c.headingMarker = hex("#b3b9c2");
c.strong = hex("#11151a");
c.emphasis = hex("#4b535c");
c.code = hex("#b4295a");
c.codeBackground = hex("#f2f4f7");
c.link = hex("#0969da");
c.linkUrl = hex("#6b7480");
c.quote = hex("#6b7480");
c.quoteMarker = hex("#c3c9d2");
c.listMarker = hex("#0969da");
c.rule = hex("#d6dae0");
c.taskDone = hex("#1a7f37");
return c;
}
ThemeColors darkColors()
{
ThemeColors c;
c.window = hex("#1b1d21");
c.windowText = hex("#d6d9dd");
c.base = hex("#1e2024");
c.alternateBase = hex("#23262b");
c.text = hex("#d6d9dd");
c.button = hex("#26292f");
c.buttonText = hex("#d6d9dd");
c.border = hex("#2d3137");
c.accent = hex("#3b8eea");
c.highlight = hex("#26466b");
c.highlightedText = hex("#eef3f8");
c.disabled = hex("#5d646d");
c.placeholder = hex("#8b939d");
c.menuBackground = hex("#242730");
c.scrollHandle = hex("#3a3f47");
c.scrollHandleHover = hex("#4d535d");
c.editorBase = hex("#1e2024");
c.editorText = hex("#d7dade");
c.gutterBackground = hex("#1a1c20");
c.gutterText = hex("#4f565f");
c.gutterActiveText = hex("#aeb5bf");
c.currentLine = hex("#23262c");
c.heading = hex("#79b8ff");
c.headingMarker = hex("#5d6570");
c.strong = hex("#f2f4f7");
c.emphasis = hex("#c3c9d1");
c.code = hex("#ff8fbf");
c.codeBackground = hex("#292c33");
c.link = hex("#62a8ff");
c.linkUrl = hex("#7f8791");
c.quote = hex("#8b939d");
c.quoteMarker = hex("#4f565f");
c.listMarker = hex("#62a8ff");
c.rule = hex("#33373e");
c.taskDone = hex("#5fbf7f");
return c;
}
} // namespace
ThemeManager *ThemeManager::instance()
{
static ThemeManager *manager = new ThemeManager();
return manager;
}
ThemeManager::ThemeManager(QObject *parent)
: QObject(parent)
{
if (qApp) {
const QColor window = qApp->palette().color(QPalette::Window);
m_paletteGuess = window.lightness() < 128 ? Scheme::Dark : Scheme::Light;
}
m_pollTimer = new QTimer(this);
m_pollTimer->setInterval(2000);
connect(m_pollTimer, &QTimer::timeout, this, &ThemeManager::pollSystemTheme);
}
void ThemeManager::initialize(Mode forcedMode, bool persist)
{
if (forcedMode != Mode::System) {
setMode(forcedMode, persist);
} else {
const QString saved = QSettings().value(QStringLiteral("theme/mode")).toString();
m_mode = modeFromString(saved, nullptr);
const Scheme scheme = (m_mode == Mode::Dark) ? Scheme::Dark
: (m_mode == Mode::Light) ? Scheme::Light
: detectSystemScheme();
applyScheme(scheme, true);
}
m_pollTimer->start();
}
void ThemeManager::setMode(Mode mode, bool persist)
{
m_mode = mode;
if (persist)
QSettings().setValue(QStringLiteral("theme/mode"), modeToString(mode));
if (m_mode == Mode::System)
applyScheme(detectSystemScheme(), true);
else
applyScheme(m_mode == Mode::Dark ? Scheme::Dark : Scheme::Light, true);
}
void ThemeManager::pollSystemTheme()
{
if (m_mode != Mode::System)
return;
const Scheme detected = detectSystemScheme();
if (detected != m_scheme)
applyScheme(detected, false);
}
void ThemeManager::applyScheme(Scheme scheme, bool force)
{
const bool changed = (scheme != m_scheme) || force;
m_scheme = scheme;
buildColors(m_scheme);
applyToApplication();
if (changed)
emit schemeChanged(m_scheme);
}
void ThemeManager::buildColors(Scheme scheme)
{
m_colors = (scheme == Scheme::Dark) ? darkColors() : lightColors();
}
void ThemeManager::applyToApplication()
{
QPalette palette;
palette.setColor(QPalette::Window, m_colors.window);
palette.setColor(QPalette::WindowText, m_colors.windowText);
palette.setColor(QPalette::Base, m_colors.base);
palette.setColor(QPalette::AlternateBase, m_colors.alternateBase);
palette.setColor(QPalette::Text, m_colors.text);
palette.setColor(QPalette::Button, m_colors.button);
palette.setColor(QPalette::ButtonText, m_colors.buttonText);
palette.setColor(QPalette::BrightText, Qt::white);
palette.setColor(QPalette::Highlight, m_colors.highlight);
palette.setColor(QPalette::HighlightedText, m_colors.highlightedText);
palette.setColor(QPalette::ToolTipBase, m_colors.menuBackground);
palette.setColor(QPalette::ToolTipText, m_colors.text);
palette.setColor(QPalette::PlaceholderText, m_colors.placeholder);
palette.setColor(QPalette::Link, m_colors.link);
palette.setColor(QPalette::LinkVisited, m_colors.link);
palette.setColor(QPalette::Disabled, QPalette::WindowText, m_colors.disabled);
palette.setColor(QPalette::Disabled, QPalette::Text, m_colors.disabled);
palette.setColor(QPalette::Disabled, QPalette::ButtonText, m_colors.disabled);
palette.setColor(QPalette::Disabled, QPalette::HighlightedText, m_colors.disabled);
qApp->setPalette(palette);
qApp->setStyleSheet(styleSheet());
}
QString ThemeManager::styleSheet() const
{
QString qss = QString::fromLatin1(R"(
QMainWindow, QDialog { background-color: @window; }
QMenuBar { background-color: @window; color: @windowText; border-bottom: 1px solid @border; }
QMenuBar::item { background: transparent; padding: 5px 10px; border-radius: 5px; }
QMenuBar::item:selected { background-color: @highlight; color: @highlightedText; }
QMenu { background-color: @menuBackground; color: @text; border: 1px solid @border; padding: 5px; }
QMenu::item { padding: 6px 28px 6px 22px; border-radius: 5px; }
QMenu::item:selected { background-color: @highlight; color: @highlightedText; }
QMenu::item:disabled { color: @disabled; }
QMenu::separator { height: 1px; background-color: @border; margin: 5px 8px; }
QStatusBar { background-color: @window; color: @placeholder; border-top: 1px solid @border; }
QStatusBar::item { border: none; }
QPlainTextEdit { background-color: @editorBase; color: @editorText; border: none;
selection-background-color: @highlight; selection-color: @highlightedText; }
QScrollBar:vertical { background: transparent; width: 11px; margin: 0; }
QScrollBar::handle:vertical { background-color: @scrollHandle; border-radius: 4px;
min-height: 28px; margin: 2px 2px; }
QScrollBar::handle:vertical:hover { background-color: @scrollHandleHover; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
QScrollBar:horizontal { background: transparent; height: 11px; margin: 0; }
QScrollBar::handle:horizontal { background-color: @scrollHandle; border-radius: 4px;
min-width: 28px; margin: 2px 2px; }
QScrollBar::handle:horizontal:hover { background-color: @scrollHandleHover; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
QPushButton { background-color: @button; color: @buttonText; border: 1px solid @border;
border-radius: 6px; padding: 5px 16px; min-width: 68px; }
QPushButton:hover { background-color: @highlight; color: @highlightedText; }
QPushButton:default { background-color: @accent; color: #ffffff; border-color: @accent; }
QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @border; padding: 4px; }
)");
// 先替换长 token,避免 @window 吃掉 @windowText 这类前缀冲突。
qss.replace(QLatin1String("@scrollHandleHover"), m_colors.scrollHandleHover.name());
qss.replace(QLatin1String("@highlightedText"), m_colors.highlightedText.name());
qss.replace(QLatin1String("@buttonText"), m_colors.buttonText.name());
qss.replace(QLatin1String("@windowText"), m_colors.windowText.name());
qss.replace(QLatin1String("@editorText"), m_colors.editorText.name());
qss.replace(QLatin1String("@menuBackground"), m_colors.menuBackground.name());
qss.replace(QLatin1String("@scrollHandle"), m_colors.scrollHandle.name());
qss.replace(QLatin1String("@editorBase"), m_colors.editorBase.name());
qss.replace(QLatin1String("@placeholder"), m_colors.placeholder.name());
qss.replace(QLatin1String("@disabled"), m_colors.disabled.name());
qss.replace(QLatin1String("@border"), m_colors.border.name());
qss.replace(QLatin1String("@accent"), m_colors.accent.name());
qss.replace(QLatin1String("@highlight"), m_colors.highlight.name());
qss.replace(QLatin1String("@button"), m_colors.button.name());
qss.replace(QLatin1String("@window"), m_colors.window.name());
qss.replace(QLatin1String("@text"), m_colors.text.name());
return qss;
}
QString ThemeManager::modeToString(Mode mode)
{
switch (mode) {
case Mode::Light: return QStringLiteral("light");
case Mode::Dark: return QStringLiteral("dark");
case Mode::System: break;
}
return QStringLiteral("system");
}
ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok)
{
const QString normalized = value.trimmed().toLower();
if (normalized == QLatin1String("light")) { if (ok) *ok = true; return Mode::Light; }
if (normalized == QLatin1String("dark")) { if (ok) *ok = true; return Mode::Dark; }
if (ok) *ok = (normalized == QLatin1String("system") || normalized.isEmpty());
return Mode::System;
}
QString ThemeManager::modeLabel(Mode mode)
{
switch (mode) {
case Mode::Light: return QObject::tr("浅色");
case Mode::Dark: return QObject::tr("深色");
case Mode::System: break;
}
return QObject::tr("跟随系统");
}
ThemeManager::Scheme ThemeManager::detectSystemScheme() const
{
// 1) 显式覆盖:打包、测试或用户强制指定时使用。
const QString override = qEnvironmentVariable("VOILETMARK_THEME").trimmed().toLower();
if (override == QLatin1String("dark"))
return Scheme::Dark;
if (override == QLatin1String("light"))
return Scheme::Light;
#if defined(Q_OS_WIN)
QSettings registry(
QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"),
QSettings::NativeFormat);
const QVariant appsUseLight = registry.value(QStringLiteral("AppsUseLightTheme"));
if (appsUseLight.isValid())
return appsUseLight.toInt() == 0 ? Scheme::Dark : Scheme::Light;
return m_paletteGuess;
#elif defined(Q_OS_MACOS)
const QString style = runCommand(QStringLiteral("defaults"),
{QStringLiteral("read"), QStringLiteral("-g"),
QStringLiteral("AppleInterfaceStyle")},
500);
return style.compare(QLatin1String("Dark"), Qt::CaseInsensitive) == 0 ? Scheme::Dark : Scheme::Light;
#else
// 2) gsettings 需要启动子进程,做 3 秒缓存;找不到命令就整段跳过。
static const QString gsettings = QStandardPaths::findExecutable(QStringLiteral("gsettings"));
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (!gsettings.isEmpty()
&& (now - m_lastGsettingsCheck > 3000 || m_cachedColorScheme.isEmpty())) {
m_cachedColorScheme = runCommand(gsettings,
{QStringLiteral("get"),
QStringLiteral("org.gnome.desktop.interface"),
QStringLiteral("color-scheme")},
500);
m_cachedGtkTheme = runCommand(gsettings,
{QStringLiteral("get"),
QStringLiteral("org.gnome.desktop.interface"),
QStringLiteral("gtk-theme")},
500);
m_lastGsettingsCheck = now;
}
const QString colorScheme = m_cachedColorScheme.toLower();
if (colorScheme.contains(QLatin1String("prefer-dark")))
return Scheme::Dark;
if (colorScheme.contains(QLatin1String("prefer-light")))
return Scheme::Light;
// 3) KDE 全局配色。
QSettings kdeGlobals(QDir::homePath() + QStringLiteral("/.config/kdeglobals"),
QSettings::IniFormat);
const QString kdeScheme = kdeGlobals.value(QStringLiteral("General/ColorScheme")).toString();
if (!kdeScheme.isEmpty())
return looksDark(kdeScheme) ? Scheme::Dark : Scheme::Light;
// 4) GTK 主题名。
if (!m_cachedGtkTheme.isEmpty())
return looksDark(m_cachedGtkTheme) ? Scheme::Dark : Scheme::Light;
// 5) XFCE 主题名。
const QString xfceName = xfceThemeName();
if (!xfceName.isEmpty())
return looksDark(xfceName) ? Scheme::Dark : Scheme::Light;
// 6) 兜底。
return m_paletteGuess;
#endif
}
+112
View File
@@ -0,0 +1,112 @@
#pragma once
#include <QColor>
#include <QObject>
class QTimer;
/// 一份完整的主题配色。界面控件与语法高亮共用同一套颜色,
/// 切换方案时只需要重建这一个结构体。
struct ThemeColors
{
// 窗口与控件
QColor window;
QColor windowText;
QColor base;
QColor alternateBase;
QColor text;
QColor button;
QColor buttonText;
QColor border;
QColor accent;
QColor highlight;
QColor highlightedText;
QColor disabled;
QColor placeholder;
QColor menuBackground;
QColor scrollHandle;
QColor scrollHandleHover;
// 编辑器
QColor editorBase;
QColor editorText;
QColor gutterBackground;
QColor gutterText;
QColor gutterActiveText;
QColor currentLine;
// Markdown 语法
QColor heading;
QColor headingMarker;
QColor strong;
QColor emphasis;
QColor code;
QColor codeBackground;
QColor link;
QColor linkUrl;
QColor quote;
QColor quoteMarker;
QColor listMarker;
QColor rule;
QColor taskDone;
};
/// 负责“跟随系统自动切换浅色 / 深色主题”。
///
/// 检测顺序:显式覆盖(环境变量 / 命令行)→ 平台自身设置
/// (Windows 注册表、macOS defaults、Linux 的 KDE / GTK / XFCE / gsettings)
/// → 启动时的调色板亮度兜底。
/// 每 2 秒轮询一次系统设置,变化后立即重绘整个界面。
class ThemeManager : public QObject
{
Q_OBJECT
public:
enum class Mode { System, Light, Dark };
enum class Scheme { Light, Dark };
static ThemeManager *instance();
/// 由 main() 在 QApplication 之后调用一次。
void initialize(Mode forcedMode, bool persist);
Mode mode() const { return m_mode; }
void setMode(Mode mode, bool persist = true);
Scheme scheme() const { return m_scheme; }
const ThemeColors &colors() const { return m_colors; }
static QString modeToString(Mode mode);
static Mode modeFromString(const QString &value, bool *ok = nullptr);
static QString modeLabel(Mode mode);
signals:
void schemeChanged(ThemeManager::Scheme scheme);
private slots:
void pollSystemTheme();
private:
explicit ThemeManager(QObject *parent = nullptr);
void applyScheme(Scheme scheme, bool force);
void buildColors(Scheme scheme);
void applyToApplication();
QString styleSheet() const;
Scheme detectSystemScheme() const;
Mode m_mode = Mode::System;
Scheme m_scheme = Scheme::Light;
ThemeColors m_colors;
QTimer *m_pollTimer = nullptr;
/// 启动时读取的 QPalette 亮度,作为所有检测手段失败后的兜底。
Scheme m_paletteGuess = Scheme::Light;
/// gsettings 调用比较昂贵,做一次短时缓存。
mutable qint64 m_lastGsettingsCheck = 0;
mutable QString m_cachedColorScheme;
mutable QString m_cachedGtkTheme;
};
+68
View File
@@ -0,0 +1,68 @@
#include "MainWindow.h"
#include "ThemeManager.h"
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QPixmap>
#include <QStyleFactory>
#include <QTimer>
int main(int argc, char *argv[])
{
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
QApplication app(argc, argv);
QApplication::setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
QCoreApplication::setOrganizationName(QStringLiteral("VoiletMark"));
QCoreApplication::setApplicationName(QStringLiteral("VoiletMark"));
QCoreApplication::setApplicationVersion(QStringLiteral(VM_VERSION));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("VoiletMark — 轻量的跨平台 Markdown 编辑器"));
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption themeOption(
QStringList{QStringLiteral("t"), QStringLiteral("theme")},
QStringLiteral("启动主题:system / light / dark(仅在本次运行生效)"),
QStringLiteral("mode"));
parser.addOption(themeOption);
QCommandLineOption screenshotOption(
QStringLiteral("screenshot"),
QStringLiteral("渲染一张当前窗口截图后退出(用于调试与视觉回归)"),
QStringLiteral("path"));
parser.addOption(screenshotOption);
parser.addPositionalArgument(QStringLiteral("file"), QStringLiteral("要打开的 Markdown 文件"));
parser.process(app);
ThemeManager::Mode mode = ThemeManager::Mode::System;
if (parser.isSet(themeOption)) {
bool ok = false;
mode = ThemeManager::modeFromString(parser.value(themeOption), &ok);
if (!ok)
mode = ThemeManager::Mode::System;
}
ThemeManager::instance()->initialize(mode, false);
MainWindow window;
window.show();
const QStringList files = parser.positionalArguments();
if (!files.isEmpty())
window.openPath(files.first());
if (parser.isSet(screenshotOption)) {
const QString path = parser.value(screenshotOption);
QTimer::singleShot(700, &window, [&window, path] {
window.grab().save(path);
QCoreApplication::quit();
});
}
return app.exec();
}