feat: v0.2.0 —— 文件夹侧栏、源代码模式、按时段主题与格式快捷键
主题 - 新增「按时段自动」模式:6:00–18:00 浅色,其余时间深色 - 主题菜单改为四种模式,默认仍为跟随系统 侧栏 - 左侧文件夹侧栏(Ctrl+B 开关),递归扫描 *.md / *.markdown / *.mdown / *.mkd - 文件 → 打开文件夹…(Ctrl+Shift+O),记住上次打开的目录 - 点击文件名即打开,当前文件在列表中高亮,支持手动刷新 编辑 - 左下角 <src> 按钮 / Ctrl+Shift+M 切换源代码模式(关高亮、行号、当前行) - Ctrl+1~6 设置标题级别,再按一次取消 - Ctrl+/ 把选中内容包成围栏代码块,再按一次拆掉(含光标在块内的情况) 其他 - 模板回归脚本验证了标题与代码块的文本变换 - README 与截图更新
This commit is contained in:
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
|
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
|
||||||
|
|
||||||
当前版本 **0.1.0**:先把编辑器本体做扎实——干净的界面、顺手的编辑手感、
|
当前版本 **0.2.0**。
|
||||||
以及**自动跟随系统**的浅色 / 深色主题。
|
|
||||||
|
|
||||||
## 界面
|
## 界面
|
||||||
|
|
||||||
@@ -11,28 +10,55 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
左侧是文件夹侧栏,递归列出目录里的所有 Markdown 文件,点一下就切换。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
### 编辑
|
### 编辑
|
||||||
|
|
||||||
- Markdown 语法高亮:标题、粗体 / 斜体 / 删除线、行内代码、围栏代码块、
|
- Markdown 语法高亮:标题、粗体 / 斜体 / 删除线、行内代码、围栏代码块、
|
||||||
链接与图片、引用、有序 / 无序列表、任务列表、分隔线
|
链接与图片、引用、有序 / 无序列表、任务列表、分隔线
|
||||||
|
- `Ctrl+1` ~ `Ctrl+6` 把选中行设成 1–6 级标题,再按一次变回正文
|
||||||
|
- `Ctrl+/` 把选中内容包成围栏代码块,再按一次拆掉
|
||||||
- 行号栏、当前行高亮
|
- 行号栏、当前行高亮
|
||||||
- `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进
|
- `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进
|
||||||
- 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号
|
- 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号
|
||||||
- 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显
|
- 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显
|
||||||
- 把 Markdown 文件直接拖进窗口即可打开
|
- 把 Markdown 文件直接拖进窗口即可打开
|
||||||
|
|
||||||
|
### 文件夹侧栏
|
||||||
|
|
||||||
|
- **文件 → 打开文件夹…**(`Ctrl+Shift+O`)选一个目录,递归扫描
|
||||||
|
`*.md` / `*.markdown` / `*.mdown` / `*.mkd`
|
||||||
|
- `Ctrl+B` 显示 / 隐藏侧栏
|
||||||
|
- 点文件名即打开,当前文件在列表里高亮;右上角「刷新」重新扫描
|
||||||
|
- 上次打开的文件夹会被记住,下次启动自动恢复
|
||||||
|
|
||||||
|
### 源代码模式
|
||||||
|
|
||||||
|
左下角状态栏的 **`<src>`** 按钮(或 `Ctrl+Shift+M`)切到源代码模式:
|
||||||
|
关掉语法高亮、行号与当前行高亮,只显示文件原始内容,适合核对格式或复制纯文本。
|
||||||
|
默认是编辑模式,按钮会显示成 `</src>` 表示当前处于源码模式。
|
||||||
|
|
||||||
### 文件
|
### 文件
|
||||||
|
|
||||||
- 新建、打开、保存、另存为、最近打开(最多 8 条)
|
- 新建、打开、保存、另存为、最近打开(最多 8 条)
|
||||||
- 保留原文件的 **BOM** 与 **CRLF** 换行风格,保存时原子写入(`QSaveFile`)
|
- 保留原文件的 **BOM** 与 **CRLF** 换行风格,保存时原子写入(`QSaveFile`)
|
||||||
- 关闭或切换文件时提示未保存的修改
|
- 关闭或切换文件时提示未保存的修改
|
||||||
- 记忆窗口位置、字号、换行开关等设置
|
- 记忆窗口位置、字号、换行开关、侧栏状态等设置
|
||||||
|
|
||||||
### 主题
|
### 主题
|
||||||
|
|
||||||
界面默认**跟随系统**自动在浅色 / 深色之间切换,每 2 秒检测一次:
|
四种模式:
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 跟随系统(默认) | 读取桌面环境的浅色 / 深色设置 |
|
||||||
|
| 浅色 | 固定浅色 |
|
||||||
|
| 深色 | 固定深色 |
|
||||||
|
| 按时段自动 | 6:00–18:00 浅色,其余时间深色 |
|
||||||
|
|
||||||
|
跟随系统时每 2 秒检测一次,改动后整套界面立即重绘:
|
||||||
|
|
||||||
| 平台 | 检测方式 |
|
| 平台 | 检测方式 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -40,7 +66,7 @@
|
|||||||
| macOS | `defaults read -g AppleInterfaceStyle` |
|
| macOS | `defaults read -g AppleInterfaceStyle` |
|
||||||
| Linux | gsettings `color-scheme` / GTK 主题名 → KDE `kdeglobals` → XFCE `xsettings.xml` → 调色板兜底 |
|
| Linux | gsettings `color-scheme` / GTK 主题名 → KDE `kdeglobals` → XFCE `xsettings.xml` → 调色板兜底 |
|
||||||
|
|
||||||
也可以在 **视图 → 主题** 中手动锁定「跟随系统 / 浅色 / 深色」,选择会被记住。
|
模式可在 **视图 → 主题** 中切换,选择会被记住。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -77,7 +103,7 @@ make -j"$(nproc)"
|
|||||||
```bash
|
```bash
|
||||||
VoiletMark # 打开编辑器(显示欢迎文档)
|
VoiletMark # 打开编辑器(显示欢迎文档)
|
||||||
VoiletMark notes.md # 直接打开指定文件
|
VoiletMark notes.md # 直接打开指定文件
|
||||||
VoiletMark --theme dark # 本次运行使用深色主题
|
VoiletMark --theme dark # 本次运行使用深色主题(system / light / dark / schedule)
|
||||||
VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉回归)
|
VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉回归)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,6 +113,11 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 |
|
| `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 |
|
||||||
| `Ctrl+Shift+S` | 另存为 |
|
| `Ctrl+Shift+S` | 另存为 |
|
||||||
|
| `Ctrl+Shift+O` | 打开文件夹 |
|
||||||
|
| `Ctrl+B` | 显示 / 隐藏侧栏 |
|
||||||
|
| `Ctrl+Shift+M` | 源代码模式 |
|
||||||
|
| `Ctrl+1` ~ `Ctrl+6` | 设为 1–6 级标题(再按取消) |
|
||||||
|
| `Ctrl+/` | 代码块(再按拆掉) |
|
||||||
| `Ctrl+Z` / `Ctrl+Shift+Z` | 撤销 / 重做 |
|
| `Ctrl+Z` / `Ctrl+Shift+Z` | 撤销 / 重做 |
|
||||||
| `Ctrl+滚轮`、`Ctrl++`、`Ctrl+-`、`Ctrl+0` | 缩放 / 恢复字号 |
|
| `Ctrl+滚轮`、`Ctrl++`、`Ctrl+-`、`Ctrl+0` | 缩放 / 恢复字号 |
|
||||||
| `Alt+Z` | 切换自动换行 |
|
| `Alt+Z` | 切换自动换行 |
|
||||||
@@ -98,9 +129,9 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.cpp 入口:命令行解析、Fusion 样式、主题初始化
|
├── main.cpp 入口:命令行解析、Fusion 样式、主题初始化
|
||||||
├── ThemeManager.{h,cpp} 系统主题检测 + 调色板 / QSS 生成
|
├── ThemeManager.{h,cpp} 四种主题模式 + 系统检测 + 调色板 / QSS 生成
|
||||||
├── MainWindow.{h,cpp} 菜单、文件读写、最近打开、状态栏、设置
|
├── MainWindow.{h,cpp} 菜单、文件读写、文件夹侧栏、状态栏、设置
|
||||||
├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、拖放、缩放
|
├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、标题 / 代码块命令、源码模式
|
||||||
└── MarkdownHighlighter.{h,cpp} Markdown 语法着色(含围栏代码块状态机)
|
└── MarkdownHighlighter.{h,cpp} Markdown 语法着色(含围栏代码块状态机)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
TEMPLATE = app
|
TEMPLATE = app
|
||||||
TARGET = VoiletMark
|
TARGET = VoiletMark
|
||||||
VERSION = 0.1.0
|
VERSION = 0.2.0
|
||||||
|
|
||||||
QT += core gui widgets
|
QT += core gui widgets
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
+261
-1
@@ -8,21 +8,32 @@
|
|||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QDockWidget>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
|
#include <QListWidget>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
|
#include <QSignalBlocker>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
#include <QTextBlock>
|
#include <QTextBlock>
|
||||||
#include <QTextCursor>
|
#include <QTextCursor>
|
||||||
#include <QTextDocument>
|
#include <QTextDocument>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -82,6 +93,7 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
setCentralWidget(m_editor);
|
setCentralWidget(m_editor);
|
||||||
|
|
||||||
createActions();
|
createActions();
|
||||||
|
createSidebar();
|
||||||
createMenus();
|
createMenus();
|
||||||
createStatusBar();
|
createStatusBar();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -169,6 +181,79 @@ void MainWindow::createActions()
|
|||||||
connect(m_editor, &QPlainTextEdit::redoAvailable, m_redoAction, &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_cutAction, &QAction::setEnabled);
|
||||||
connect(m_editor, &QPlainTextEdit::copyAvailable, m_copyAction, &QAction::setEnabled);
|
connect(m_editor, &QPlainTextEdit::copyAvailable, m_copyAction, &QAction::setEnabled);
|
||||||
|
|
||||||
|
m_openFolderAction = new QAction(tr("打开文件夹…"), this);
|
||||||
|
m_openFolderAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+O")));
|
||||||
|
connect(m_openFolderAction, &QAction::triggered, this, &MainWindow::chooseFolder);
|
||||||
|
|
||||||
|
m_sidebarAction = new QAction(tr("侧栏"), this);
|
||||||
|
m_sidebarAction->setCheckable(true);
|
||||||
|
m_sidebarAction->setChecked(true);
|
||||||
|
m_sidebarAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+B")));
|
||||||
|
connect(m_sidebarAction, &QAction::toggled, this, [this](bool visible) {
|
||||||
|
if (m_sidebar)
|
||||||
|
m_sidebar->setVisible(visible);
|
||||||
|
QSettings().setValue(QStringLiteral("window/sidebarVisible"), visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
m_sourceModeAction = new QAction(tr("源代码模式"), this);
|
||||||
|
m_sourceModeAction->setCheckable(true);
|
||||||
|
m_sourceModeAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+M")));
|
||||||
|
connect(m_sourceModeAction, &QAction::toggled, this, &MainWindow::setSourceModeEnabled);
|
||||||
|
|
||||||
|
m_codeBlockAction = new QAction(tr("代码块"), this);
|
||||||
|
m_codeBlockAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+/")));
|
||||||
|
connect(m_codeBlockAction, &QAction::triggered, this, [this] {
|
||||||
|
m_editor->toggleCodeBlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int level = 1; level <= 6; ++level) {
|
||||||
|
QAction *action = new QAction(tr("标题 %1").arg(level), this);
|
||||||
|
action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(level)));
|
||||||
|
connect(action, &QAction::triggered, this, [this, level] {
|
||||||
|
m_editor->setHeadingLevel(level);
|
||||||
|
});
|
||||||
|
m_headingActions[level - 1] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createSidebar()
|
||||||
|
{
|
||||||
|
m_sidebar = new QDockWidget(tr("文件夹"), this);
|
||||||
|
m_sidebar->setObjectName(QStringLiteral("folderDock"));
|
||||||
|
m_sidebar->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
|
||||||
|
m_sidebar->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||||||
|
m_sidebar->setTitleBarWidget(new QWidget(m_sidebar));
|
||||||
|
|
||||||
|
QWidget *content = new QWidget(m_sidebar);
|
||||||
|
QVBoxLayout *layout = new QVBoxLayout(content);
|
||||||
|
layout->setContentsMargins(8, 8, 8, 8);
|
||||||
|
layout->setSpacing(6);
|
||||||
|
|
||||||
|
QHBoxLayout *header = new QHBoxLayout();
|
||||||
|
header->setSpacing(4);
|
||||||
|
m_folderLabel = new QLabel(tr("未打开文件夹"), content);
|
||||||
|
m_folderLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||||
|
QToolButton *refreshButton = new QToolButton(content);
|
||||||
|
refreshButton->setText(tr("刷新"));
|
||||||
|
refreshButton->setToolTip(tr("重新扫描文件夹里的 Markdown 文件"));
|
||||||
|
header->addWidget(m_folderLabel, 1);
|
||||||
|
header->addWidget(refreshButton);
|
||||||
|
layout->addLayout(header);
|
||||||
|
|
||||||
|
m_fileList = new QListWidget(content);
|
||||||
|
m_fileList->setFrameShape(QFrame::NoFrame);
|
||||||
|
m_fileList->setUniformItemSizes(true);
|
||||||
|
m_fileList->setTextElideMode(Qt::ElideMiddle);
|
||||||
|
m_fileList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
layout->addWidget(m_fileList, 1);
|
||||||
|
|
||||||
|
m_sidebar->setWidget(content);
|
||||||
|
addDockWidget(Qt::LeftDockWidgetArea, m_sidebar);
|
||||||
|
resizeDocks({m_sidebar}, {250}, Qt::Horizontal);
|
||||||
|
|
||||||
|
connect(refreshButton, &QToolButton::clicked, this, &MainWindow::refreshFolder);
|
||||||
|
connect(m_fileList, &QListWidget::itemClicked, this, &MainWindow::openSidebarItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::createMenus()
|
void MainWindow::createMenus()
|
||||||
@@ -176,6 +261,7 @@ void MainWindow::createMenus()
|
|||||||
QMenu *fileMenu = menuBar()->addMenu(tr("文件"));
|
QMenu *fileMenu = menuBar()->addMenu(tr("文件"));
|
||||||
fileMenu->addAction(m_newAction);
|
fileMenu->addAction(m_newAction);
|
||||||
fileMenu->addAction(m_openAction);
|
fileMenu->addAction(m_openAction);
|
||||||
|
fileMenu->addAction(m_openFolderAction);
|
||||||
m_recentMenu = fileMenu->addMenu(tr("最近打开"));
|
m_recentMenu = fileMenu->addMenu(tr("最近打开"));
|
||||||
fileMenu->addSeparator();
|
fileMenu->addSeparator();
|
||||||
fileMenu->addAction(m_saveAction);
|
fileMenu->addAction(m_saveAction);
|
||||||
@@ -194,6 +280,9 @@ void MainWindow::createMenus()
|
|||||||
editMenu->addAction(m_selectAllAction);
|
editMenu->addAction(m_selectAllAction);
|
||||||
|
|
||||||
QMenu *viewMenu = menuBar()->addMenu(tr("视图"));
|
QMenu *viewMenu = menuBar()->addMenu(tr("视图"));
|
||||||
|
viewMenu->addAction(m_sidebarAction);
|
||||||
|
viewMenu->addAction(m_sourceModeAction);
|
||||||
|
viewMenu->addSeparator();
|
||||||
viewMenu->addAction(m_wrapAction);
|
viewMenu->addAction(m_wrapAction);
|
||||||
viewMenu->addSeparator();
|
viewMenu->addSeparator();
|
||||||
viewMenu->addAction(m_zoomInAction);
|
viewMenu->addAction(m_zoomInAction);
|
||||||
@@ -208,7 +297,10 @@ void MainWindow::createMenus()
|
|||||||
m_themeSystemAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::System), this);
|
m_themeSystemAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::System), this);
|
||||||
m_themeLightAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Light), this);
|
m_themeLightAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Light), this);
|
||||||
m_themeDarkAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Dark), this);
|
m_themeDarkAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Dark), this);
|
||||||
for (QAction *action : {m_themeSystemAction, m_themeLightAction, m_themeDarkAction}) {
|
m_themeScheduleAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Schedule), this);
|
||||||
|
m_themeScheduleAction->setToolTip(tr("6:00–18:00 使用浅色,其余时间使用深色"));
|
||||||
|
for (QAction *action : {m_themeSystemAction, m_themeLightAction, m_themeDarkAction,
|
||||||
|
m_themeScheduleAction}) {
|
||||||
action->setCheckable(true);
|
action->setCheckable(true);
|
||||||
themeGroup->addAction(action);
|
themeGroup->addAction(action);
|
||||||
themeMenu->addAction(action);
|
themeMenu->addAction(action);
|
||||||
@@ -221,6 +313,16 @@ void MainWindow::createMenus()
|
|||||||
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Light)); });
|
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Light)); });
|
||||||
connect(m_themeDarkAction, &QAction::triggered, this,
|
connect(m_themeDarkAction, &QAction::triggered, this,
|
||||||
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Dark)); });
|
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Dark)); });
|
||||||
|
connect(m_themeScheduleAction, &QAction::triggered, this,
|
||||||
|
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Schedule)); });
|
||||||
|
|
||||||
|
QMenu *formatMenu = menuBar()->addMenu(tr("格式"));
|
||||||
|
QMenu *headingMenu = formatMenu->addMenu(tr("标题"));
|
||||||
|
for (QAction *headingAction : m_headingActions) {
|
||||||
|
if (headingAction)
|
||||||
|
headingMenu->addAction(headingAction);
|
||||||
|
}
|
||||||
|
formatMenu->addAction(m_codeBlockAction);
|
||||||
|
|
||||||
QMenu *helpMenu = menuBar()->addMenu(tr("帮助"));
|
QMenu *helpMenu = menuBar()->addMenu(tr("帮助"));
|
||||||
helpMenu->addAction(tr("关于 VoiletMark"), this, &MainWindow::showAbout);
|
helpMenu->addAction(tr("关于 VoiletMark"), this, &MainWindow::showAbout);
|
||||||
@@ -228,10 +330,20 @@ void MainWindow::createMenus()
|
|||||||
|
|
||||||
void MainWindow::createStatusBar()
|
void MainWindow::createStatusBar()
|
||||||
{
|
{
|
||||||
|
// 左下角的 <src> 开关:切到源代码模式。
|
||||||
|
m_sourceButton = new QToolButton(this);
|
||||||
|
m_sourceButton->setText(QStringLiteral("<src>"));
|
||||||
|
m_sourceButton->setCheckable(true);
|
||||||
|
m_sourceButton->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_sourceButton->setToolTip(tr("源代码模式:关闭语法高亮与行号(Ctrl+Shift+M)"));
|
||||||
|
statusBar()->addWidget(m_sourceButton);
|
||||||
|
|
||||||
m_statusLabel = new QLabel(this);
|
m_statusLabel = new QLabel(this);
|
||||||
m_statusLabel->setContentsMargins(8, 0, 10, 0);
|
m_statusLabel->setContentsMargins(8, 0, 10, 0);
|
||||||
statusBar()->addPermanentWidget(m_statusLabel);
|
statusBar()->addPermanentWidget(m_statusLabel);
|
||||||
statusBar()->setSizeGripEnabled(false);
|
statusBar()->setSizeGripEnabled(false);
|
||||||
|
|
||||||
|
connect(m_sourceButton, &QToolButton::toggled, this, &MainWindow::setSourceModeEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::loadSettings()
|
void MainWindow::loadSettings()
|
||||||
@@ -259,6 +371,21 @@ void MainWindow::loadSettings()
|
|||||||
m_themeSystemAction->setChecked(mode == ThemeManager::Mode::System);
|
m_themeSystemAction->setChecked(mode == ThemeManager::Mode::System);
|
||||||
m_themeLightAction->setChecked(mode == ThemeManager::Mode::Light);
|
m_themeLightAction->setChecked(mode == ThemeManager::Mode::Light);
|
||||||
m_themeDarkAction->setChecked(mode == ThemeManager::Mode::Dark);
|
m_themeDarkAction->setChecked(mode == ThemeManager::Mode::Dark);
|
||||||
|
m_themeScheduleAction->setChecked(mode == ThemeManager::Mode::Schedule);
|
||||||
|
|
||||||
|
m_folderPath = settings.value(QStringLiteral("files/folder")).toString();
|
||||||
|
if (!m_folderPath.isEmpty() && QFileInfo(m_folderPath).isDir())
|
||||||
|
refreshFolder();
|
||||||
|
else
|
||||||
|
m_folderPath.clear();
|
||||||
|
|
||||||
|
const bool sidebarVisible =
|
||||||
|
settings.value(QStringLiteral("window/sidebarVisible"), true).toBool();
|
||||||
|
m_sidebar->setVisible(sidebarVisible);
|
||||||
|
m_sidebarAction->setChecked(sidebarVisible);
|
||||||
|
|
||||||
|
const bool sourceMode = settings.value(QStringLiteral("editor/sourceMode"), false).toBool();
|
||||||
|
setSourceModeEnabled(sourceMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::saveSettings()
|
void MainWindow::saveSettings()
|
||||||
@@ -269,6 +396,8 @@ void MainWindow::saveSettings()
|
|||||||
settings.setValue(QStringLiteral("editor/wordWrap"), m_wrapAction->isChecked());
|
settings.setValue(QStringLiteral("editor/wordWrap"), m_wrapAction->isChecked());
|
||||||
settings.setValue(QStringLiteral("files/lastDirectory"), m_lastDirectory);
|
settings.setValue(QStringLiteral("files/lastDirectory"), m_lastDirectory);
|
||||||
settings.setValue(QStringLiteral("files/recent"), m_recentFiles);
|
settings.setValue(QStringLiteral("files/recent"), m_recentFiles);
|
||||||
|
settings.setValue(QStringLiteral("files/folder"), m_folderPath);
|
||||||
|
settings.setValue(QStringLiteral("window/sidebarVisible"), m_sidebar->isVisible());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::closeEvent(QCloseEvent *event)
|
void MainWindow::closeEvent(QCloseEvent *event)
|
||||||
@@ -455,6 +584,7 @@ void MainWindow::setCurrentFile(const QString &path)
|
|||||||
setWindowFilePath(QString());
|
setWindowFilePath(QString());
|
||||||
}
|
}
|
||||||
updateWindowTitle();
|
updateWindowTitle();
|
||||||
|
selectInSidebar(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::updateWindowTitle()
|
void MainWindow::updateWindowTitle()
|
||||||
@@ -565,3 +695,133 @@ void MainWindow::refreshRecentMenu()
|
|||||||
refreshRecentMenu();
|
refreshRecentMenu();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::chooseFolder()
|
||||||
|
{
|
||||||
|
const QString directory = QFileDialog::getExistingDirectory(
|
||||||
|
this, tr("打开文件夹"), startDirectory(), QFileDialog::ShowDirsOnly);
|
||||||
|
if (!directory.isEmpty())
|
||||||
|
openFolder(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openFolder(const QString &path)
|
||||||
|
{
|
||||||
|
const QFileInfo info(path);
|
||||||
|
if (!info.isDir())
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_folderPath = info.absoluteFilePath();
|
||||||
|
m_lastDirectory = m_folderPath;
|
||||||
|
QSettings().setValue(QStringLiteral("files/folder"), m_folderPath);
|
||||||
|
refreshFolder();
|
||||||
|
|
||||||
|
m_sidebar->setVisible(true);
|
||||||
|
m_sidebarAction->setChecked(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::refreshFolder()
|
||||||
|
{
|
||||||
|
if (!m_fileList)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_fileList->clear();
|
||||||
|
|
||||||
|
if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir()) {
|
||||||
|
m_folderLabel->setText(tr("未打开文件夹"));
|
||||||
|
m_folderLabel->setToolTip(tr("文件 → 打开文件夹…"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir root(m_folderPath);
|
||||||
|
m_folderLabel->setText(root.dirName().isEmpty() ? m_folderPath : root.dirName());
|
||||||
|
m_folderLabel->setToolTip(QDir::toNativeSeparators(m_folderPath));
|
||||||
|
|
||||||
|
QStringList files;
|
||||||
|
QDirIterator iterator(m_folderPath,
|
||||||
|
{QStringLiteral("*.md"), QStringLiteral("*.markdown"),
|
||||||
|
QStringLiteral("*.mdown"), QStringLiteral("*.mkd")},
|
||||||
|
QDir::Files, QDirIterator::Subdirectories);
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
const QString filePath = iterator.next();
|
||||||
|
const QString relative = root.relativeFilePath(filePath);
|
||||||
|
if (relative.startsWith(QLatin1Char('.')) || relative.contains(QLatin1String("/.")))
|
||||||
|
continue;
|
||||||
|
files.append(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(files.begin(), files.end(), [&root](const QString &left, const QString &right) {
|
||||||
|
return root.relativeFilePath(left).compare(root.relativeFilePath(right),
|
||||||
|
Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const QString &filePath : files) {
|
||||||
|
QListWidgetItem *item = new QListWidgetItem(root.relativeFilePath(filePath), m_fileList);
|
||||||
|
item->setData(Qt::UserRole, filePath);
|
||||||
|
item->setToolTip(QDir::toNativeSeparators(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
QListWidgetItem *empty = new QListWidgetItem(tr("(没有 Markdown 文件)"), m_fileList);
|
||||||
|
empty->setFlags(Qt::NoItemFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectInSidebar(m_currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openSidebarItem(QListWidgetItem *item)
|
||||||
|
{
|
||||||
|
if (!item)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString path = item->data(Qt::UserRole).toString();
|
||||||
|
if (path.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!m_currentPath.isEmpty()
|
||||||
|
&& QFileInfo(path).absoluteFilePath() == QFileInfo(m_currentPath).absoluteFilePath()
|
||||||
|
&& !m_editor->document()->isModified()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::selectInSidebar(const QString &path)
|
||||||
|
{
|
||||||
|
if (!m_fileList || m_fileList->count() == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString absolute = path.isEmpty() ? QString() : QFileInfo(path).absoluteFilePath();
|
||||||
|
if (!absolute.isEmpty()) {
|
||||||
|
for (int index = 0; index < m_fileList->count(); ++index) {
|
||||||
|
QListWidgetItem *item = m_fileList->item(index);
|
||||||
|
const QString itemPath = item->data(Qt::UserRole).toString();
|
||||||
|
if (itemPath.isEmpty())
|
||||||
|
continue;
|
||||||
|
if (QFileInfo(itemPath).absoluteFilePath() == absolute) {
|
||||||
|
m_fileList->setCurrentItem(item);
|
||||||
|
m_fileList->scrollToItem(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_fileList->clearSelection();
|
||||||
|
m_fileList->setCurrentItem(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::setSourceModeEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
m_editor->setSourceMode(enabled);
|
||||||
|
|
||||||
|
if (m_sourceButton) {
|
||||||
|
const QSignalBlocker blocker(m_sourceButton);
|
||||||
|
m_sourceButton->setChecked(enabled);
|
||||||
|
m_sourceButton->setText(enabled ? QStringLiteral("</src>") : QStringLiteral("<src>"));
|
||||||
|
}
|
||||||
|
if (m_sourceModeAction) {
|
||||||
|
const QSignalBlocker blocker(m_sourceModeAction);
|
||||||
|
m_sourceModeAction->setChecked(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSettings().setValue(QStringLiteral("editor/sourceMode"), enabled);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
|
|
||||||
class QAction;
|
class QAction;
|
||||||
class QActionGroup;
|
class QActionGroup;
|
||||||
|
class QDockWidget;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QListWidget;
|
||||||
|
class QListWidgetItem;
|
||||||
class QMenu;
|
class QMenu;
|
||||||
class QTextDocument;
|
class QTextDocument;
|
||||||
|
class QToolButton;
|
||||||
class MarkdownEditor;
|
class MarkdownEditor;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow
|
class MainWindow : public QMainWindow
|
||||||
@@ -26,6 +30,7 @@ private:
|
|||||||
enum class Eol { Lf, Crlf };
|
enum class Eol { Lf, Crlf };
|
||||||
|
|
||||||
void createActions();
|
void createActions();
|
||||||
|
void createSidebar();
|
||||||
void createMenus();
|
void createMenus();
|
||||||
void createStatusBar();
|
void createStatusBar();
|
||||||
void loadSettings();
|
void loadSettings();
|
||||||
@@ -44,6 +49,13 @@ private:
|
|||||||
void refreshRecentMenu();
|
void refreshRecentMenu();
|
||||||
void loadRecentFiles();
|
void loadRecentFiles();
|
||||||
|
|
||||||
|
void chooseFolder();
|
||||||
|
void openFolder(const QString &path);
|
||||||
|
void refreshFolder();
|
||||||
|
void openSidebarItem(QListWidgetItem *item);
|
||||||
|
void selectInSidebar(const QString &path);
|
||||||
|
void setSourceModeEnabled(bool enabled);
|
||||||
|
|
||||||
void changeZoom(int direction);
|
void changeZoom(int direction);
|
||||||
void resetZoom();
|
void resetZoom();
|
||||||
void setWordWrapEnabled(bool enabled);
|
void setWordWrapEnabled(bool enabled);
|
||||||
@@ -60,6 +72,12 @@ private:
|
|||||||
MarkdownEditor *m_editor = nullptr;
|
MarkdownEditor *m_editor = nullptr;
|
||||||
QLabel *m_statusLabel = nullptr;
|
QLabel *m_statusLabel = nullptr;
|
||||||
|
|
||||||
|
QDockWidget *m_sidebar = nullptr;
|
||||||
|
QListWidget *m_fileList = nullptr;
|
||||||
|
QLabel *m_folderLabel = nullptr;
|
||||||
|
QToolButton *m_sourceButton = nullptr;
|
||||||
|
QString m_folderPath;
|
||||||
|
|
||||||
QString m_currentPath;
|
QString m_currentPath;
|
||||||
Eol m_eol = Eol::Lf;
|
Eol m_eol = Eol::Lf;
|
||||||
bool m_hasBom = false;
|
bool m_hasBom = false;
|
||||||
@@ -87,6 +105,13 @@ private:
|
|||||||
QAction *m_themeSystemAction = nullptr;
|
QAction *m_themeSystemAction = nullptr;
|
||||||
QAction *m_themeLightAction = nullptr;
|
QAction *m_themeLightAction = nullptr;
|
||||||
QAction *m_themeDarkAction = nullptr;
|
QAction *m_themeDarkAction = nullptr;
|
||||||
|
QAction *m_themeScheduleAction = nullptr;
|
||||||
|
|
||||||
|
QAction *m_openFolderAction = nullptr;
|
||||||
|
QAction *m_sidebarAction = nullptr;
|
||||||
|
QAction *m_sourceModeAction = nullptr;
|
||||||
|
QAction *m_codeBlockAction = nullptr;
|
||||||
|
QAction *m_headingActions[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
|
||||||
|
|
||||||
QMenu *m_recentMenu = nullptr;
|
QMenu *m_recentMenu = nullptr;
|
||||||
};
|
};
|
||||||
|
|||||||
+219
-1
@@ -12,7 +12,9 @@
|
|||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QTextBlock>
|
#include <QTextBlock>
|
||||||
|
#include <QTextCursor>
|
||||||
#include <QTextOption>
|
#include <QTextOption>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QWheelEvent>
|
#include <QWheelEvent>
|
||||||
@@ -21,6 +23,17 @@ namespace {
|
|||||||
|
|
||||||
constexpr int kLineNumberRightPadding = 14;
|
constexpr int kLineNumberRightPadding = 14;
|
||||||
|
|
||||||
|
const QString &fenceMarker()
|
||||||
|
{
|
||||||
|
static const QString marker = QString(QChar(96)).repeated(3);
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFenceLine(const QTextBlock &block)
|
||||||
|
{
|
||||||
|
return block.isValid() && block.text().trimmed().startsWith(fenceMarker());
|
||||||
|
}
|
||||||
|
|
||||||
class LineNumberArea : public QWidget
|
class LineNumberArea : public QWidget
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -150,7 +163,7 @@ void MarkdownEditor::applyEditorFont()
|
|||||||
|
|
||||||
void MarkdownEditor::updateLineNumberAreaWidth(int)
|
void MarkdownEditor::updateLineNumberAreaWidth(int)
|
||||||
{
|
{
|
||||||
setViewportMargins(lineNumberAreaWidth(), 0, 0, 0);
|
setViewportMargins(m_sourceMode ? 0 : lineNumberAreaWidth(), 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy)
|
void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy)
|
||||||
@@ -166,6 +179,11 @@ void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy)
|
|||||||
|
|
||||||
void MarkdownEditor::updateCurrentLineHighlight()
|
void MarkdownEditor::updateCurrentLineHighlight()
|
||||||
{
|
{
|
||||||
|
if (m_sourceMode) {
|
||||||
|
setExtraSelections({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
QTextEdit::ExtraSelection selection;
|
QTextEdit::ExtraSelection selection;
|
||||||
selection.format.setBackground(ThemeManager::instance()->colors().currentLine);
|
selection.format.setBackground(ThemeManager::instance()->colors().currentLine);
|
||||||
selection.format.setProperty(QTextFormat::FullWidthSelection, true);
|
selection.format.setProperty(QTextFormat::FullWidthSelection, true);
|
||||||
@@ -181,6 +199,206 @@ void MarkdownEditor::applyTheme()
|
|||||||
viewport()->update();
|
viewport()->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MarkdownEditor::setSourceMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_sourceMode == enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_sourceMode = enabled;
|
||||||
|
m_highlighter->setHighlightingEnabled(!enabled);
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
setExtraSelections({});
|
||||||
|
else
|
||||||
|
updateCurrentLineHighlight();
|
||||||
|
|
||||||
|
m_lineNumberArea->setVisible(!enabled);
|
||||||
|
updateLineNumberAreaWidth(0);
|
||||||
|
|
||||||
|
const QRect rect = contentsRect();
|
||||||
|
m_lineNumberArea->setGeometry(QRect(rect.left(), rect.top(),
|
||||||
|
lineNumberAreaWidth(), rect.height()));
|
||||||
|
|
||||||
|
viewport()->update();
|
||||||
|
emit sourceModeChanged(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MarkdownEditor::selectedBlockRange(int *firstBlock, int *lastBlock) const
|
||||||
|
{
|
||||||
|
const QTextCursor cursor = textCursor();
|
||||||
|
int first = cursor.blockNumber();
|
||||||
|
int last = first;
|
||||||
|
|
||||||
|
if (cursor.hasSelection()) {
|
||||||
|
QTextCursor start(document());
|
||||||
|
start.setPosition(cursor.selectionStart());
|
||||||
|
QTextCursor end(document());
|
||||||
|
end.setPosition(cursor.selectionEnd());
|
||||||
|
first = start.blockNumber();
|
||||||
|
last = end.blockNumber();
|
||||||
|
// 选区正好停在下一行行首时,不要把那一行算进来。
|
||||||
|
if (end.positionInBlock() == 0 && last > first)
|
||||||
|
--last;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstBlock) *firstBlock = first;
|
||||||
|
if (lastBlock) *lastBlock = last;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MarkdownEditor::selectBlocks(int firstBlock, int lastBlock)
|
||||||
|
{
|
||||||
|
const QTextBlock first = document()->findBlockByNumber(firstBlock);
|
||||||
|
const QTextBlock last = document()->findBlockByNumber(lastBlock);
|
||||||
|
if (!first.isValid() || !last.isValid())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QTextCursor cursor(document());
|
||||||
|
cursor.setPosition(first.position());
|
||||||
|
cursor.setPosition(last.position() + last.text().length(), QTextCursor::KeepAnchor);
|
||||||
|
setTextCursor(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MarkdownEditor::setHeadingLevel(int level)
|
||||||
|
{
|
||||||
|
level = qBound(0, level, 6);
|
||||||
|
|
||||||
|
int firstBlock = 0;
|
||||||
|
int lastBlock = 0;
|
||||||
|
selectedBlockRange(&firstBlock, &lastBlock);
|
||||||
|
|
||||||
|
static const QRegularExpression headingPattern(QStringLiteral("^\\s{0,3}#{1,6}\\s+"));
|
||||||
|
|
||||||
|
bool alreadySame = level > 0;
|
||||||
|
if (alreadySame) {
|
||||||
|
const QString prefix = QString(level, QChar('#')).append(QLatin1Char(' '));
|
||||||
|
for (int index = firstBlock; index <= lastBlock; ++index) {
|
||||||
|
if (!document()->findBlockByNumber(index).text().startsWith(prefix)) {
|
||||||
|
alreadySame = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int target = alreadySame ? 0 : level;
|
||||||
|
const QString newPrefix = target > 0
|
||||||
|
? QString(target, QChar('#')).append(QLatin1Char(' '))
|
||||||
|
: QString();
|
||||||
|
|
||||||
|
QTextCursor edit(document());
|
||||||
|
edit.beginEditBlock();
|
||||||
|
for (int index = firstBlock; index <= lastBlock; ++index) {
|
||||||
|
const QTextBlock block = document()->findBlockByNumber(index);
|
||||||
|
const QString original = block.text();
|
||||||
|
QString text = original;
|
||||||
|
text.remove(headingPattern);
|
||||||
|
if (!newPrefix.isEmpty())
|
||||||
|
text.prepend(newPrefix);
|
||||||
|
|
||||||
|
QTextCursor blockCursor(block);
|
||||||
|
blockCursor.setPosition(block.position());
|
||||||
|
blockCursor.setPosition(block.position() + original.length(), QTextCursor::KeepAnchor);
|
||||||
|
blockCursor.insertText(text);
|
||||||
|
}
|
||||||
|
edit.endEditBlock();
|
||||||
|
|
||||||
|
selectBlocks(firstBlock, lastBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MarkdownEditor::toggleCodeBlock()
|
||||||
|
{
|
||||||
|
int firstBlockNumber = 0;
|
||||||
|
int lastBlockNumber = 0;
|
||||||
|
selectedBlockRange(&firstBlockNumber, &lastBlockNumber);
|
||||||
|
|
||||||
|
const QTextBlock first = document()->findBlockByNumber(firstBlockNumber);
|
||||||
|
const QTextBlock last = document()->findBlockByNumber(lastBlockNumber);
|
||||||
|
if (!first.isValid() || !last.isValid())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 整块删掉一行(含行尾换行)。
|
||||||
|
const auto removeBlock = [this](const QTextBlock &block) {
|
||||||
|
QTextCursor cursor(block);
|
||||||
|
cursor.setPosition(block.position());
|
||||||
|
const int end = qMin(block.position() + block.length(), document()->characterCount() - 1);
|
||||||
|
cursor.setPosition(end, QTextCursor::KeepAnchor);
|
||||||
|
cursor.removeSelectedText();
|
||||||
|
};
|
||||||
|
|
||||||
|
QTextCursor edit(document());
|
||||||
|
|
||||||
|
// 1) 选中的首尾行本身就是围栏 → 拆掉这两行。
|
||||||
|
if (lastBlockNumber > firstBlockNumber && isFenceLine(first) && isFenceLine(last)) {
|
||||||
|
edit.beginEditBlock();
|
||||||
|
removeBlock(last);
|
||||||
|
removeBlock(first);
|
||||||
|
edit.endEditBlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 选中内容整体落在某个围栏块内部 → 拆掉外层围栏。
|
||||||
|
QTextBlock opener;
|
||||||
|
QTextBlock closer;
|
||||||
|
if (enclosingFence(firstBlockNumber, lastBlockNumber, &opener, &closer)) {
|
||||||
|
edit.beginEditBlock();
|
||||||
|
removeBlock(closer);
|
||||||
|
removeBlock(opener);
|
||||||
|
edit.endEditBlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 包成围栏代码块。
|
||||||
|
const int startPosition = first.position();
|
||||||
|
const int endPosition = last.position() + last.text().length();
|
||||||
|
|
||||||
|
edit.beginEditBlock();
|
||||||
|
QTextCursor tail(document());
|
||||||
|
tail.setPosition(endPosition);
|
||||||
|
tail.insertText(QLatin1Char('\n') + fenceMarker());
|
||||||
|
QTextCursor head(document());
|
||||||
|
head.setPosition(startPosition);
|
||||||
|
head.insertText(fenceMarker() + QLatin1Char('\n'));
|
||||||
|
edit.endEditBlock();
|
||||||
|
|
||||||
|
QTextCursor selection(document());
|
||||||
|
selection.setPosition(startPosition);
|
||||||
|
selection.setPosition(endPosition + 8, QTextCursor::KeepAnchor);
|
||||||
|
setTextCursor(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MarkdownEditor::enclosingFence(int firstBlockNumber, int lastBlockNumber,
|
||||||
|
QTextBlock *opener, QTextBlock *closer) const
|
||||||
|
{
|
||||||
|
bool inFence = false;
|
||||||
|
bool captured = false;
|
||||||
|
QTextBlock candidate;
|
||||||
|
|
||||||
|
for (QTextBlock block = document()->firstBlock(); block.isValid(); block = block.next()) {
|
||||||
|
const int number = block.blockNumber();
|
||||||
|
const bool fenceLine = isFenceLine(block);
|
||||||
|
|
||||||
|
if (number == firstBlockNumber) {
|
||||||
|
captured = inFence;
|
||||||
|
if (opener)
|
||||||
|
*opener = candidate;
|
||||||
|
}
|
||||||
|
if (captured && number > lastBlockNumber && fenceLine) {
|
||||||
|
if (closer)
|
||||||
|
*closer = block;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (fenceLine) {
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
candidate = block;
|
||||||
|
} else {
|
||||||
|
inFence = false;
|
||||||
|
candidate = QTextBlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void MarkdownEditor::resizeEvent(QResizeEvent *event)
|
void MarkdownEditor::resizeEvent(QResizeEvent *event)
|
||||||
{
|
{
|
||||||
QPlainTextEdit::resizeEvent(event);
|
QPlainTextEdit::resizeEvent(event);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
|
|
||||||
class MarkdownHighlighter;
|
class MarkdownHighlighter;
|
||||||
|
class QTextBlock;
|
||||||
|
|
||||||
/// 编辑器本体:等宽字体、行号栏、当前行高亮、Tab 缩进、
|
/// 编辑器本体:等宽字体、行号栏、当前行高亮、Tab 缩进、
|
||||||
/// Ctrl+滚轮缩放,以及把拖入的文件转交给主窗口打开。
|
/// Ctrl+滚轮缩放,以及把拖入的文件转交给主窗口打开。
|
||||||
@@ -21,9 +22,19 @@ public:
|
|||||||
|
|
||||||
MarkdownHighlighter *highlighter() const { return m_highlighter; }
|
MarkdownHighlighter *highlighter() const { return m_highlighter; }
|
||||||
|
|
||||||
|
bool isSourceMode() const { return m_sourceMode; }
|
||||||
|
/// 源代码模式:关掉语法高亮、行号与当前行高亮,只看文件原始内容。
|
||||||
|
void setSourceMode(bool enabled);
|
||||||
|
|
||||||
|
/// 把选中的行设为 N 级标题(1–6);整段已经是这一级时改回正文。
|
||||||
|
void setHeadingLevel(int level);
|
||||||
|
/// 把选中内容包成围栏代码块;如果已经围起来了则拆掉。
|
||||||
|
void toggleCodeBlock();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void zoomStepRequested(int direction);
|
void zoomStepRequested(int direction);
|
||||||
void fileDropped(const QString &path);
|
void fileDropped(const QString &path);
|
||||||
|
void sourceModeChanged(bool enabled);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void resizeEvent(QResizeEvent *event) override;
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
@@ -43,8 +54,13 @@ private:
|
|||||||
void applyEditorFont();
|
void applyEditorFont();
|
||||||
void indentSelection(bool outdent);
|
void indentSelection(bool outdent);
|
||||||
bool acceptsLocalFiles(const QMimeData *mime) const;
|
bool acceptsLocalFiles(const QMimeData *mime) const;
|
||||||
|
void selectedBlockRange(int *firstBlock, int *lastBlock) const;
|
||||||
|
void selectBlocks(int firstBlock, int lastBlock);
|
||||||
|
bool enclosingFence(int firstBlock, int lastBlock,
|
||||||
|
QTextBlock *opener, QTextBlock *closer) const;
|
||||||
|
|
||||||
QWidget *m_lineNumberArea = nullptr;
|
QWidget *m_lineNumberArea = nullptr;
|
||||||
MarkdownHighlighter *m_highlighter = nullptr;
|
MarkdownHighlighter *m_highlighter = nullptr;
|
||||||
int m_fontPointSize = 12;
|
int m_fontPointSize = 12;
|
||||||
|
bool m_sourceMode = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,10 +90,18 @@ void MarkdownHighlighter::applyPattern(const QRegularExpression &pattern, const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MarkdownHighlighter::setHighlightingEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_enabled == enabled)
|
||||||
|
return;
|
||||||
|
m_enabled = enabled;
|
||||||
|
rehighlight();
|
||||||
|
}
|
||||||
|
|
||||||
void MarkdownHighlighter::highlightBlock(const QString &text)
|
void MarkdownHighlighter::highlightBlock(const QString &text)
|
||||||
{
|
{
|
||||||
setCurrentBlockState(StateNormal);
|
setCurrentBlockState(StateNormal);
|
||||||
if (text.isEmpty())
|
if (!m_enabled || text.isEmpty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)"));
|
static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)"));
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class MarkdownHighlighter : public QSyntaxHighlighter
|
|||||||
public:
|
public:
|
||||||
explicit MarkdownHighlighter(QTextDocument *document = nullptr);
|
explicit MarkdownHighlighter(QTextDocument *document = nullptr);
|
||||||
|
|
||||||
|
/// 源代码模式会关掉着色,但不改文档内容(rehighlight 会清掉旧格式)。
|
||||||
|
void setHighlightingEnabled(bool enabled);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void highlightBlock(const QString &text) override;
|
void highlightBlock(const QString &text) override;
|
||||||
|
|
||||||
@@ -38,4 +41,5 @@ private:
|
|||||||
QTextCharFormat m_ruleFormat;
|
QTextCharFormat m_ruleFormat;
|
||||||
QTextCharFormat m_taskDoneFormat;
|
QTextCharFormat m_taskDoneFormat;
|
||||||
QTextCharFormat m_strikeFormat;
|
QTextCharFormat m_strikeFormat;
|
||||||
|
bool m_enabled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
+52
-20
@@ -9,6 +9,7 @@
|
|||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QTime>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QXmlStreamReader>
|
#include <QXmlStreamReader>
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ ThemeColors lightColors()
|
|||||||
c.menuBackground = hex("#ffffff");
|
c.menuBackground = hex("#ffffff");
|
||||||
c.scrollHandle = hex("#c9ced6");
|
c.scrollHandle = hex("#c9ced6");
|
||||||
c.scrollHandleHover = hex("#aeb5bf");
|
c.scrollHandleHover = hex("#aeb5bf");
|
||||||
|
c.sidebarBackground = hex("#f8f9fb");
|
||||||
|
c.hoverBackground = hex("#eaedf1");
|
||||||
|
|
||||||
c.editorBase = hex("#ffffff");
|
c.editorBase = hex("#ffffff");
|
||||||
c.editorText = hex("#24292f");
|
c.editorText = hex("#24292f");
|
||||||
@@ -119,6 +122,8 @@ ThemeColors darkColors()
|
|||||||
c.menuBackground = hex("#242730");
|
c.menuBackground = hex("#242730");
|
||||||
c.scrollHandle = hex("#3a3f47");
|
c.scrollHandle = hex("#3a3f47");
|
||||||
c.scrollHandleHover = hex("#4d535d");
|
c.scrollHandleHover = hex("#4d535d");
|
||||||
|
c.sidebarBackground = hex("#1a1c20");
|
||||||
|
c.hoverBackground = hex("#272b31");
|
||||||
|
|
||||||
c.editorBase = hex("#1e2024");
|
c.editorBase = hex("#1e2024");
|
||||||
c.editorText = hex("#d7dade");
|
c.editorText = hex("#d7dade");
|
||||||
@@ -171,10 +176,7 @@ void ThemeManager::initialize(Mode forcedMode, bool persist)
|
|||||||
} else {
|
} else {
|
||||||
const QString saved = QSettings().value(QStringLiteral("theme/mode")).toString();
|
const QString saved = QSettings().value(QStringLiteral("theme/mode")).toString();
|
||||||
m_mode = modeFromString(saved, nullptr);
|
m_mode = modeFromString(saved, nullptr);
|
||||||
const Scheme scheme = (m_mode == Mode::Dark) ? Scheme::Dark
|
applyScheme(schemeForMode(m_mode), true);
|
||||||
: (m_mode == Mode::Light) ? Scheme::Light
|
|
||||||
: detectSystemScheme();
|
|
||||||
applyScheme(scheme, true);
|
|
||||||
}
|
}
|
||||||
m_pollTimer->start();
|
m_pollTimer->start();
|
||||||
}
|
}
|
||||||
@@ -185,20 +187,35 @@ void ThemeManager::setMode(Mode mode, bool persist)
|
|||||||
if (persist)
|
if (persist)
|
||||||
QSettings().setValue(QStringLiteral("theme/mode"), modeToString(mode));
|
QSettings().setValue(QStringLiteral("theme/mode"), modeToString(mode));
|
||||||
|
|
||||||
if (m_mode == Mode::System)
|
applyScheme(schemeForMode(m_mode), true);
|
||||||
applyScheme(detectSystemScheme(), true);
|
|
||||||
else
|
|
||||||
applyScheme(m_mode == Mode::Dark ? Scheme::Dark : Scheme::Light, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ThemeManager::pollSystemTheme()
|
void ThemeManager::pollSystemTheme()
|
||||||
{
|
{
|
||||||
if (m_mode != Mode::System)
|
if (m_mode == Mode::Light || m_mode == Mode::Dark)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const Scheme detected = detectSystemScheme();
|
// 跟随系统:读桌面设置;按时段:到 6:00 / 18:00 自动翻转。
|
||||||
if (detected != m_scheme)
|
const Scheme target = schemeForMode(m_mode);
|
||||||
applyScheme(detected, false);
|
if (target != m_scheme)
|
||||||
|
applyScheme(target, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeManager::Scheme ThemeManager::schemeForMode(Mode mode) const
|
||||||
|
{
|
||||||
|
switch (mode) {
|
||||||
|
case Mode::Light: return Scheme::Light;
|
||||||
|
case Mode::Dark: return Scheme::Dark;
|
||||||
|
case Mode::Schedule: return scheduleScheme();
|
||||||
|
case Mode::System: break;
|
||||||
|
}
|
||||||
|
return detectSystemScheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeManager::Scheme ThemeManager::scheduleScheme() const
|
||||||
|
{
|
||||||
|
const int hour = QTime::currentTime().hour();
|
||||||
|
return (hour >= 6 && hour < 18) ? Scheme::Light : Scheme::Dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ThemeManager::applyScheme(Scheme scheme, bool force)
|
void ThemeManager::applyScheme(Scheme scheme, bool force)
|
||||||
@@ -276,6 +293,16 @@ QPushButton { background-color: @button; color: @buttonText; border: 1px solid @
|
|||||||
QPushButton:hover { background-color: @highlight; color: @highlightedText; }
|
QPushButton:hover { background-color: @highlight; color: @highlightedText; }
|
||||||
QPushButton:default { background-color: @accent; color: #ffffff; border-color: @accent; }
|
QPushButton:default { background-color: @accent; color: #ffffff; border-color: @accent; }
|
||||||
QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @border; padding: 4px; }
|
QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @border; padding: 4px; }
|
||||||
|
QToolButton { border: none; border-radius: 5px; padding: 3px 8px; color: @text; background: transparent; }
|
||||||
|
QToolButton:hover { background-color: @hoverBackground; }
|
||||||
|
QToolButton:checked { background-color: @highlight; color: @highlightedText; }
|
||||||
|
QDockWidget { color: @text; }
|
||||||
|
QDockWidget::title { background-color: @window; padding: 6px 8px; }
|
||||||
|
QListWidget { background-color: @sidebarBackground; border: 1px solid @border; border-radius: 6px;
|
||||||
|
padding: 3px; outline: 0; }
|
||||||
|
QListWidget::item { padding: 4px 6px; border-radius: 4px; }
|
||||||
|
QListWidget::item:hover { background-color: @hoverBackground; }
|
||||||
|
QListWidget::item:selected { background-color: @highlight; color: @highlightedText; }
|
||||||
)");
|
)");
|
||||||
|
|
||||||
// 先替换长 token,避免 @window 吃掉 @windowText 这类前缀冲突。
|
// 先替换长 token,避免 @window 吃掉 @windowText 这类前缀冲突。
|
||||||
@@ -290,6 +317,8 @@ QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @b
|
|||||||
qss.replace(QLatin1String("@placeholder"), m_colors.placeholder.name());
|
qss.replace(QLatin1String("@placeholder"), m_colors.placeholder.name());
|
||||||
qss.replace(QLatin1String("@disabled"), m_colors.disabled.name());
|
qss.replace(QLatin1String("@disabled"), m_colors.disabled.name());
|
||||||
qss.replace(QLatin1String("@border"), m_colors.border.name());
|
qss.replace(QLatin1String("@border"), m_colors.border.name());
|
||||||
|
qss.replace(QLatin1String("@hoverBackground"), m_colors.hoverBackground.name());
|
||||||
|
qss.replace(QLatin1String("@sidebarBackground"), m_colors.sidebarBackground.name());
|
||||||
qss.replace(QLatin1String("@accent"), m_colors.accent.name());
|
qss.replace(QLatin1String("@accent"), m_colors.accent.name());
|
||||||
qss.replace(QLatin1String("@highlight"), m_colors.highlight.name());
|
qss.replace(QLatin1String("@highlight"), m_colors.highlight.name());
|
||||||
qss.replace(QLatin1String("@button"), m_colors.button.name());
|
qss.replace(QLatin1String("@button"), m_colors.button.name());
|
||||||
@@ -301,9 +330,10 @@ QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @b
|
|||||||
QString ThemeManager::modeToString(Mode mode)
|
QString ThemeManager::modeToString(Mode mode)
|
||||||
{
|
{
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case Mode::Light: return QStringLiteral("light");
|
case Mode::Light: return QStringLiteral("light");
|
||||||
case Mode::Dark: return QStringLiteral("dark");
|
case Mode::Dark: return QStringLiteral("dark");
|
||||||
case Mode::System: break;
|
case Mode::Schedule: return QStringLiteral("schedule");
|
||||||
|
case Mode::System: break;
|
||||||
}
|
}
|
||||||
return QStringLiteral("system");
|
return QStringLiteral("system");
|
||||||
}
|
}
|
||||||
@@ -311,8 +341,9 @@ QString ThemeManager::modeToString(Mode mode)
|
|||||||
ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok)
|
ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok)
|
||||||
{
|
{
|
||||||
const QString normalized = value.trimmed().toLower();
|
const QString normalized = value.trimmed().toLower();
|
||||||
if (normalized == QLatin1String("light")) { if (ok) *ok = true; return Mode::Light; }
|
if (normalized == QLatin1String("light")) { if (ok) *ok = true; return Mode::Light; }
|
||||||
if (normalized == QLatin1String("dark")) { if (ok) *ok = true; return Mode::Dark; }
|
if (normalized == QLatin1String("dark")) { if (ok) *ok = true; return Mode::Dark; }
|
||||||
|
if (normalized == QLatin1String("schedule")) { if (ok) *ok = true; return Mode::Schedule; }
|
||||||
if (ok) *ok = (normalized == QLatin1String("system") || normalized.isEmpty());
|
if (ok) *ok = (normalized == QLatin1String("system") || normalized.isEmpty());
|
||||||
return Mode::System;
|
return Mode::System;
|
||||||
}
|
}
|
||||||
@@ -320,9 +351,10 @@ ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok)
|
|||||||
QString ThemeManager::modeLabel(Mode mode)
|
QString ThemeManager::modeLabel(Mode mode)
|
||||||
{
|
{
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case Mode::Light: return QObject::tr("浅色");
|
case Mode::Light: return QObject::tr("浅色");
|
||||||
case Mode::Dark: return QObject::tr("深色");
|
case Mode::Dark: return QObject::tr("深色");
|
||||||
case Mode::System: break;
|
case Mode::Schedule: return QObject::tr("按时段自动");
|
||||||
|
case Mode::System: break;
|
||||||
}
|
}
|
||||||
return QObject::tr("跟随系统");
|
return QObject::tr("跟随系统");
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -26,6 +26,8 @@ struct ThemeColors
|
|||||||
QColor menuBackground;
|
QColor menuBackground;
|
||||||
QColor scrollHandle;
|
QColor scrollHandle;
|
||||||
QColor scrollHandleHover;
|
QColor scrollHandleHover;
|
||||||
|
QColor sidebarBackground;
|
||||||
|
QColor hoverBackground;
|
||||||
|
|
||||||
// 编辑器
|
// 编辑器
|
||||||
QColor editorBase;
|
QColor editorBase;
|
||||||
@@ -53,16 +55,17 @@ struct ThemeColors
|
|||||||
|
|
||||||
/// 负责“跟随系统自动切换浅色 / 深色主题”。
|
/// 负责“跟随系统自动切换浅色 / 深色主题”。
|
||||||
///
|
///
|
||||||
/// 检测顺序:显式覆盖(环境变量 / 命令行)→ 平台自身设置
|
/// 四种模式:跟随系统 / 浅色 / 深色 / 按时段自动(6:00 起浅色、18:00 起深色)。
|
||||||
|
/// 跟随系统时的检测顺序:显式覆盖(环境变量 / 命令行)→ 平台自身设置
|
||||||
/// (Windows 注册表、macOS defaults、Linux 的 KDE / GTK / XFCE / gsettings)
|
/// (Windows 注册表、macOS defaults、Linux 的 KDE / GTK / XFCE / gsettings)
|
||||||
/// → 启动时的调色板亮度兜底。
|
/// → 启动时的调色板亮度兜底。
|
||||||
/// 每 2 秒轮询一次系统设置,变化后立即重绘整个界面。
|
/// 每 2 秒轮询一次,变化后立即重绘整个界面。
|
||||||
class ThemeManager : public QObject
|
class ThemeManager : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum class Mode { System, Light, Dark };
|
enum class Mode { System, Light, Dark, Schedule };
|
||||||
enum class Scheme { Light, Dark };
|
enum class Scheme { Light, Dark };
|
||||||
|
|
||||||
static ThemeManager *instance();
|
static ThemeManager *instance();
|
||||||
@@ -95,6 +98,9 @@ private:
|
|||||||
QString styleSheet() const;
|
QString styleSheet() const;
|
||||||
|
|
||||||
Scheme detectSystemScheme() const;
|
Scheme detectSystemScheme() const;
|
||||||
|
/// 6:00–18:00 浅色,其余时间深色。
|
||||||
|
Scheme scheduleScheme() const;
|
||||||
|
Scheme schemeForMode(Mode mode) const;
|
||||||
|
|
||||||
Mode m_mode = Mode::System;
|
Mode m_mode = Mode::System;
|
||||||
Scheme m_scheme = Scheme::Light;
|
Scheme m_scheme = Scheme::Light;
|
||||||
|
|||||||
Reference in New Issue
Block a user