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:
@@ -0,0 +1,8 @@
|
|||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
*.user
|
||||||
|
*.autosave
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
compile_commands.json
|
||||||
|
CMakeLists.txt.user
|
||||||
@@ -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 .)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# VoiletMark
|
||||||
|
|
||||||
|
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
|
||||||
|
|
||||||
|
当前版本 **0.1.0**:先把编辑器本体做扎实——干净的界面、顺手的编辑手感、
|
||||||
|
以及**自动跟随系统**的浅色 / 深色主题。
|
||||||
|
|
||||||
|
## 界面
|
||||||
|
|
||||||
|
| 浅色 | 深色 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
### 编辑
|
||||||
|
|
||||||
|
- 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 |
Executable
+27
@@ -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"
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user