commit a1016ac7d7ab8188ed238c4b864babddebd6f85f Author: liyepan Date: Thu Oct 22 17:06:27 2026 +0800 feat: VoiletMark 0.1.0 —— Qt5/C++ Markdown 编辑器 - 自动跟随系统的浅色/深色主题(Windows 注册表、macOS defaults、 Linux gsettings / KDE / XFCE),每 2 秒检测,可在菜单中手动锁定 - Markdown 语法高亮(标题、强调、行内代码、围栏代码块、链接、 引用、列表、任务列表、分隔线) - 编辑器:行号栏、当前行高亮、Tab 缩进、拖放打开、Ctrl+滚轮缩放 - 文件:新建/打开/保存/另存为/最近打开,保留 BOM 与 CRLF,QSaveFile 原子写入 - 状态栏、未保存提示、窗口与编辑设置持久化 - CMake 构建脚本与 README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e64f91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +build-*/ +*.user +*.autosave +.vscode/ +.idea/ +compile_commands.json +CMakeLists.txt.user diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9832176 --- /dev/null +++ b/CMakeLists.txt @@ -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 .) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d23ca47 --- /dev/null +++ b/README.md @@ -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 diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png new file mode 100644 index 0000000..e4055cf Binary files /dev/null and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png new file mode 100644 index 0000000..53a3c6f Binary files /dev/null and b/docs/screenshot-light.png differ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..e99c1e9 --- /dev/null +++ b/scripts/build.sh @@ -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" diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..3243d95 --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,567 @@ +#include "MainWindow.h" + +#include "MarkdownEditor.h" +#include "ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 + +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(ThemeManager::Mode::System)); }); + connect(m_themeLightAction, &QAction::triggered, this, + [this] { applyThemeMode(static_cast(ThemeManager::Mode::Light)); }); + connect(m_themeDarkAction, &QAction::triggered, this, + [this] { applyThemeMode(static_cast(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(mode), true); +} + +void MainWindow::showAbout() +{ + QMessageBox::about( + this, tr("关于 VoiletMark"), + tr("

VoiletMark %1

" + "

一个轻量的跨平台 Markdown 编辑器。

" + "

基于 Qt %2 与 C++17 构建,界面会跟随系统自动切换浅色 / 深色主题。

") + .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(); + }); +} diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..4633ed9 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +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; +}; diff --git a/src/MarkdownEditor.cpp b/src/MarkdownEditor.cpp new file mode 100644 index 0000000..e396ddb --- /dev/null +++ b/src/MarkdownEditor.cpp @@ -0,0 +1,314 @@ +#include "MarkdownEditor.h" + +#include "MarkdownHighlighter.h" +#include "ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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); +} diff --git a/src/MarkdownEditor.h b/src/MarkdownEditor.h new file mode 100644 index 0000000..964d2a1 --- /dev/null +++ b/src/MarkdownEditor.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +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; +}; diff --git a/src/MarkdownHighlighter.cpp b/src/MarkdownHighlighter.cpp new file mode 100644 index 0000000..4ac4206 --- /dev/null +++ b/src/MarkdownHighlighter.cpp @@ -0,0 +1,190 @@ +#include "MarkdownHighlighter.h" + +#include "ThemeManager.h" + +#include + +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("(? +#include +#include + +/// 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; +}; diff --git a/src/ThemeManager.cpp b/src/ThemeManager.cpp new file mode 100644 index 0000000..1680ca5 --- /dev/null +++ b/src/ThemeManager.cpp @@ -0,0 +1,397 @@ +#include "ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 +} diff --git a/src/ThemeManager.h b/src/ThemeManager.h new file mode 100644 index 0000000..5463043 --- /dev/null +++ b/src/ThemeManager.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include + +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; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a7dd5ea --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,68 @@ +#include "MainWindow.h" +#include "ThemeManager.h" + +#include +#include +#include +#include +#include +#include + +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(); +}