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