- 侧栏顶部两个页签:文件(默认)/ 目录,切换状态会记住 - 目录页遍历当前文档的 1–6 级标题:按层级用全角空格缩进, H1/H2 加粗,深层级字号略小,鼠标悬停显示完整标题 - 围栏代码块里的 # 不会被当成标题(扫描时跟踪围栏状态) - 点目录项跳到对应行并居中;编辑停下 250ms 后自动重建目录 - 目录模式下隐藏文件夹名和 Git 工具条 - 新增 4 条翻译(目录 / Outline、没有标题 等) - 版本 0.11.0
1580 lines
55 KiB
C++
1580 lines
55 KiB
C++
#include "MainWindow.h"
|
||
|
||
#include "GitRunner.h"
|
||
#include "LanguageManager.h"
|
||
#include "MarkdownEditor.h"
|
||
#include "MarkdownRenderer.h"
|
||
#include "ThemeManager.h"
|
||
|
||
#include <QAction>
|
||
#include <QActionGroup>
|
||
#include <QApplication>
|
||
#include <QCloseEvent>
|
||
#include <QDateTime>
|
||
#include <QDir>
|
||
#include <QDirIterator>
|
||
#include <QDockWidget>
|
||
#include <QFile>
|
||
#include <QFileDialog>
|
||
#include <QFileInfo>
|
||
#include <QFrame>
|
||
#include <QGridLayout>
|
||
#include <QHBoxLayout>
|
||
#include <QInputDialog>
|
||
#include <QLabel>
|
||
#include <QListWidget>
|
||
#include <QMenu>
|
||
#include <QMenuBar>
|
||
#include <QMessageBox>
|
||
#include <QPageLayout>
|
||
#include <QPageSize>
|
||
#include <QPdfWriter>
|
||
#include <QPixmap>
|
||
#include <QProcess>
|
||
#include <QPushButton>
|
||
#include <QSaveFile>
|
||
#include <QSettings>
|
||
#include <QSignalBlocker>
|
||
#include <QStackedWidget>
|
||
#include <QStandardPaths>
|
||
#include <QStatusBar>
|
||
#include <QTextBlock>
|
||
#include <QTextStream>
|
||
#include <QTextCursor>
|
||
#include <QTextDocument>
|
||
#include <QTimer>
|
||
#include <QToolButton>
|
||
#include <QVBoxLayout>
|
||
#include <QWidget>
|
||
|
||
#include <algorithm>
|
||
|
||
namespace {
|
||
|
||
constexpr int kMaxRecentFiles = 8;
|
||
constexpr int kDefaultFontSize = 12;
|
||
|
||
// Linux 桌面上 Qt 会尝试 GTK 原生文件选择器,dconf / gvfs 不可用时它会卡住,
|
||
// 所以只在 Linux 上强制使用 Qt 自己的对话框;Windows / macOS 保留原生对话框。
|
||
QFileDialog::Options fileDialogOptions()
|
||
{
|
||
#if defined(Q_OS_LINUX)
|
||
return QFileDialog::DontUseNativeDialog;
|
||
#else
|
||
return QFileDialog::Options();
|
||
#endif
|
||
}
|
||
|
||
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. 另一个有序列表项
|
||
|
||
| 功能 | 状态 |
|
||
| --- | :---: |
|
||
| 语法高亮 | 完成 |
|
||
| 导出 PDF | 完成 |
|
||
| Git 同步 | 完成 |
|
||
|
||
- [x] 自动深浅色主题
|
||
- [x] Markdown 语法高亮
|
||
- [x] 导出 PDF
|
||
|
||
```cpp
|
||
#include <cstdio>
|
||
|
||
int main()
|
||
{
|
||
std::printf("Hello, VoiletMark!\n");
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
---
|
||
)VOILETMARK";
|
||
|
||
const char kWelcomeTextEn[] = R"VOILETMARK(
|
||
# Welcome to VoiletMark
|
||
|
||
A lightweight cross-platform Markdown editor built with **Qt 5** and C++.
|
||
|
||
## Quick start
|
||
|
||
- `Ctrl+O` opens a file, `Ctrl+S` saves, `Ctrl+Shift+S` saves as
|
||
- Drag a Markdown file into the window to open it
|
||
- `Ctrl+1` – `Ctrl+6` set heading levels, `Ctrl+/` wraps a code block
|
||
- `Ctrl+E` exports the document to PDF (A4)
|
||
|
||
## Modes
|
||
|
||
The default view is the editor: Markdown markers are hidden, headings are sized,
|
||
and the line under the cursor reveals its raw markers.
|
||
Click `<src>` in the status bar to see the source with line numbers.
|
||
|
||
## Theme
|
||
|
||
Follows the system light / dark setting by default;
|
||
switch it under **View → Theme**.
|
||
|
||
> Quotes, lists, `inline code`, **bold**, *italic*, ~~strikethrough~~
|
||
> and [links](https://example.com) all render in place.
|
||
|
||
| Feature | Status |
|
||
| --- | :---: |
|
||
| Syntax highlighting | Done |
|
||
| PDF export | Done |
|
||
| Git sync | Done |
|
||
|
||
- [x] Syntax highlighting
|
||
- [x] Folder sidebar
|
||
- [x] PDF export
|
||
|
||
```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);
|
||
|
||
m_git = new GitRunner(this);
|
||
connect(m_git, &GitRunner::finished, this, &MainWindow::onGitFinished);
|
||
|
||
createActions();
|
||
createSidebar();
|
||
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);
|
||
connect(LanguageManager::instance(), &LanguageManager::languageChanged,
|
||
this, &MainWindow::retranslateUi);
|
||
|
||
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_exportPdfAction = new QAction(tr("导出 PDF…"), this);
|
||
m_exportPdfAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+E")));
|
||
connect(m_exportPdfAction, &QAction::triggered, this, &MainWindow::exportPdf);
|
||
|
||
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);
|
||
|
||
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);
|
||
if (m_sidebarButton) {
|
||
const QSignalBlocker blocker(m_sidebarButton);
|
||
m_sidebarButton->setChecked(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 *modeLayout = new QHBoxLayout();
|
||
modeLayout->setContentsMargins(0, 0, 0, 0);
|
||
modeLayout->setSpacing(4);
|
||
|
||
auto makeModeButton = [this, content](const QString &text) {
|
||
QToolButton *button = new QToolButton(content);
|
||
button->setText(text);
|
||
button->setCheckable(true);
|
||
button->setCursor(Qt::PointingHandCursor);
|
||
button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||
return button;
|
||
};
|
||
|
||
m_fileModeButton = makeModeButton(tr("文件"));
|
||
m_tocModeButton = makeModeButton(tr("目录"));
|
||
m_fileModeButton->setChecked(true);
|
||
modeLayout->addWidget(m_fileModeButton);
|
||
modeLayout->addWidget(m_tocModeButton);
|
||
layout->addLayout(modeLayout);
|
||
|
||
connect(m_fileModeButton, &QToolButton::clicked, this, [this] { switchSidebarMode(0); });
|
||
connect(m_tocModeButton, &QToolButton::clicked, this, [this] { switchSidebarMode(1); });
|
||
|
||
m_folderLabel = new QLabel(tr("未打开文件夹"), content);
|
||
m_folderLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||
layout->addWidget(m_folderLabel);
|
||
|
||
m_sidebarStack = new QStackedWidget(content);
|
||
|
||
m_fileList = new QListWidget(m_sidebarStack);
|
||
m_fileList->setFrameShape(QFrame::NoFrame);
|
||
m_fileList->setUniformItemSizes(true);
|
||
m_fileList->setTextElideMode(Qt::ElideMiddle);
|
||
m_fileList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||
m_fileList->setContextMenuPolicy(Qt::CustomContextMenu);
|
||
m_sidebarStack->addWidget(m_fileList);
|
||
|
||
m_tocList = new QListWidget(m_sidebarStack);
|
||
m_tocList->setFrameShape(QFrame::NoFrame);
|
||
m_tocList->setTextElideMode(Qt::ElideRight);
|
||
m_tocList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||
m_sidebarStack->addWidget(m_tocList);
|
||
|
||
layout->addWidget(m_sidebarStack, 1);
|
||
|
||
connect(m_tocList, &QListWidget::itemClicked, this, &MainWindow::goToTocItem);
|
||
|
||
m_tocTimer = new QTimer(this);
|
||
m_tocTimer->setSingleShot(true);
|
||
m_tocTimer->setInterval(250);
|
||
connect(m_tocTimer, &QTimer::timeout, this, [this] {
|
||
if (m_sidebarMode == 1)
|
||
rebuildToc();
|
||
});
|
||
connect(m_editor, &QPlainTextEdit::textChanged, this, [this] {
|
||
if (m_sidebarMode == 1)
|
||
m_tocTimer->start();
|
||
});
|
||
|
||
// ---- Git 工具条:只有打开的是仓库时才显示 ----
|
||
m_gitBar = new QWidget(content);
|
||
QGridLayout *gitLayout = new QGridLayout(m_gitBar);
|
||
gitLayout->setContentsMargins(0, 2, 0, 0);
|
||
gitLayout->setSpacing(4);
|
||
|
||
m_gitStatusLabel = new QLabel(tr("Git 仓库"), m_gitBar);
|
||
m_gitStatusLabel->setForegroundRole(QPalette::PlaceholderText);
|
||
gitLayout->addWidget(m_gitStatusLabel, 0, 0, 1, 3);
|
||
|
||
auto makeGitButton = [this](const QString &text, const QString &tip) {
|
||
QPushButton *button = new QPushButton(text, m_gitBar);
|
||
button->setToolTip(tip);
|
||
button->setCursor(Qt::PointingHandCursor);
|
||
button->setStyleSheet(QStringLiteral("padding: 3px 6px; min-width: 0;"));
|
||
return button;
|
||
};
|
||
|
||
m_gitFetchButton = makeGitButton(tr("获取"), tr("git fetch --all --prune"));
|
||
m_gitPullButton = makeGitButton(tr("拉取"), tr("git pull --ff-only"));
|
||
m_gitStageButton = makeGitButton(tr("添加"), tr("git add -A"));
|
||
m_gitCommitButton = makeGitButton(tr("提交"), tr("把暂存区的内容提交"));
|
||
m_gitPushButton = makeGitButton(tr("推送"), tr("git push"));
|
||
|
||
gitLayout->addWidget(m_gitFetchButton, 1, 0);
|
||
gitLayout->addWidget(m_gitPullButton, 1, 1);
|
||
gitLayout->addWidget(m_gitStageButton, 1, 2);
|
||
gitLayout->addWidget(m_gitCommitButton, 2, 0, 1, 2);
|
||
gitLayout->addWidget(m_gitPushButton, 2, 2);
|
||
|
||
layout->addWidget(m_gitBar);
|
||
m_gitBar->setVisible(false);
|
||
|
||
connect(m_gitPullButton, &QPushButton::clicked, this, &MainWindow::gitPull);
|
||
connect(m_gitFetchButton, &QPushButton::clicked, this, &MainWindow::gitFetch);
|
||
connect(m_gitPushButton, &QPushButton::clicked, this, &MainWindow::gitPush);
|
||
connect(m_gitStageButton, &QPushButton::clicked, this, &MainWindow::gitStageAll);
|
||
connect(m_gitCommitButton, &QPushButton::clicked, this, &MainWindow::gitCommit);
|
||
connect(m_fileList, &QListWidget::customContextMenuRequested,
|
||
this, &MainWindow::showFileContextMenu);
|
||
|
||
m_sidebar->setWidget(content);
|
||
addDockWidget(Qt::LeftDockWidgetArea, m_sidebar);
|
||
resizeDocks({m_sidebar}, {250}, Qt::Horizontal);
|
||
|
||
connect(m_fileList, &QListWidget::itemClicked, this, &MainWindow::openSidebarItem);
|
||
|
||
// 列表自己刷新:没有“刷新”按钮,每 5 秒比对一次文件集合,变了才重建。
|
||
m_folderRefreshTimer = new QTimer(this);
|
||
m_folderRefreshTimer->setInterval(5000);
|
||
connect(m_folderRefreshTimer, &QTimer::timeout, this, &MainWindow::refreshFolder);
|
||
m_folderRefreshTimer->start();
|
||
}
|
||
|
||
void MainWindow::createMenus()
|
||
{
|
||
m_fileMenu = menuBar()->addMenu(tr("文件"));
|
||
m_fileMenu->addAction(m_newAction);
|
||
m_fileMenu->addAction(m_openAction);
|
||
m_fileMenu->addAction(m_openFolderAction);
|
||
m_recentMenu = m_fileMenu->addMenu(tr("最近打开"));
|
||
m_fileMenu->addSeparator();
|
||
m_fileMenu->addAction(m_saveAction);
|
||
m_fileMenu->addAction(m_saveAsAction);
|
||
m_fileMenu->addAction(m_exportPdfAction);
|
||
m_fileMenu->addSeparator();
|
||
m_fileMenu->addAction(m_quitAction);
|
||
|
||
m_editMenu = menuBar()->addMenu(tr("编辑"));
|
||
m_editMenu->addAction(m_undoAction);
|
||
m_editMenu->addAction(m_redoAction);
|
||
m_editMenu->addSeparator();
|
||
m_editMenu->addAction(m_cutAction);
|
||
m_editMenu->addAction(m_copyAction);
|
||
m_editMenu->addAction(m_pasteAction);
|
||
m_editMenu->addSeparator();
|
||
m_editMenu->addAction(m_selectAllAction);
|
||
|
||
m_viewMenu = menuBar()->addMenu(tr("视图"));
|
||
m_viewMenu->addAction(m_sidebarAction);
|
||
m_viewMenu->addAction(m_sourceModeAction);
|
||
m_viewMenu->addSeparator();
|
||
m_viewMenu->addAction(m_wrapAction);
|
||
m_viewMenu->addSeparator();
|
||
m_viewMenu->addAction(m_zoomInAction);
|
||
m_viewMenu->addAction(m_zoomOutAction);
|
||
m_viewMenu->addAction(m_zoomResetAction);
|
||
m_viewMenu->addSeparator();
|
||
|
||
m_themeMenu = m_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);
|
||
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);
|
||
themeGroup->addAction(action);
|
||
m_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)); });
|
||
connect(m_themeScheduleAction, &QAction::triggered, this,
|
||
[this] { applyThemeMode(static_cast<int>(ThemeManager::Mode::Schedule)); });
|
||
|
||
m_languageMenu = m_viewMenu->addMenu(tr("语言"));
|
||
QActionGroup *languageGroup = new QActionGroup(this);
|
||
languageGroup->setExclusive(true);
|
||
m_languageChineseAction =
|
||
new QAction(LanguageManager::displayName(QStringLiteral("zh_CN")), this);
|
||
m_languageEnglishAction =
|
||
new QAction(LanguageManager::displayName(QStringLiteral("en")), this);
|
||
for (QAction *action : {m_languageChineseAction, m_languageEnglishAction}) {
|
||
action->setCheckable(true);
|
||
languageGroup->addAction(action);
|
||
m_languageMenu->addAction(action);
|
||
}
|
||
connect(m_languageChineseAction, &QAction::triggered, this,
|
||
[this] { applyLanguage(QStringLiteral("zh_CN")); });
|
||
connect(m_languageEnglishAction, &QAction::triggered, this,
|
||
[this] { applyLanguage(QStringLiteral("en")); });
|
||
|
||
m_formatMenu = menuBar()->addMenu(tr("格式"));
|
||
m_headingMenu = m_formatMenu->addMenu(tr("标题"));
|
||
for (QAction *headingAction : m_headingActions) {
|
||
if (headingAction)
|
||
m_headingMenu->addAction(headingAction);
|
||
}
|
||
m_formatMenu->addAction(m_codeBlockAction);
|
||
|
||
m_helpMenu = menuBar()->addMenu(tr("帮助"));
|
||
m_aboutAction = m_helpMenu->addAction(tr("关于 VoiletMark"), this, &MainWindow::showAbout);
|
||
}
|
||
|
||
void MainWindow::createStatusBar()
|
||
{
|
||
// 左下角:侧栏开关 + 源代码模式开关。
|
||
m_sidebarButton = new QToolButton(this);
|
||
m_sidebarButton->setText(tr("侧栏"));
|
||
m_sidebarButton->setCheckable(true);
|
||
m_sidebarButton->setChecked(true);
|
||
m_sidebarButton->setCursor(Qt::PointingHandCursor);
|
||
m_sidebarButton->setToolTip(tr("显示 / 隐藏文件夹侧栏(Ctrl+B)"));
|
||
statusBar()->addWidget(m_sidebarButton);
|
||
connect(m_sidebarButton, &QToolButton::toggled, m_sidebarAction, &QAction::setChecked);
|
||
|
||
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->setContentsMargins(8, 0, 10, 0);
|
||
statusBar()->addPermanentWidget(m_statusLabel);
|
||
statusBar()->setSizeGripEnabled(false);
|
||
|
||
connect(m_sourceButton, &QToolButton::toggled, this, &MainWindow::setSourceModeEnabled);
|
||
}
|
||
|
||
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);
|
||
m_themeScheduleAction->setChecked(mode == ThemeManager::Mode::Schedule);
|
||
|
||
const int sidebarMode =
|
||
settings.value(QStringLiteral("window/sidebarMode"), 0).toInt();
|
||
switchSidebarMode(sidebarMode);
|
||
|
||
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);
|
||
|
||
updateGitState();
|
||
}
|
||
|
||
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);
|
||
settings.setValue(QStringLiteral("files/folder"), m_folderPath);
|
||
settings.setValue(QStringLiteral("window/sidebarVisible"), m_sidebar->isVisible());
|
||
}
|
||
|
||
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()
|
||
{
|
||
const bool english = LanguageManager::instance()->language() == QLatin1String("en");
|
||
QString text = QString::fromUtf8(english ? kWelcomeTextEn : kWelcomeText);
|
||
if (text.startsWith(QLatin1Char('\n')))
|
||
text.remove(0, 1);
|
||
m_welcomeText = text;
|
||
m_editor->setPlainText(text);
|
||
m_editor->applyBlockRhythm();
|
||
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->applyBlockRhythm();
|
||
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);;所有文件 (*)"),
|
||
nullptr, fileDialogOptions());
|
||
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->applyBlockRhythm();
|
||
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);;所有文件 (*)"),
|
||
nullptr, fileDialogOptions());
|
||
if (path.isEmpty())
|
||
return false;
|
||
|
||
if (QFileInfo(path).suffix().isEmpty())
|
||
path += QStringLiteral(".md");
|
||
|
||
return writeToFile(path);
|
||
}
|
||
|
||
void MainWindow::logExport(const QString &message)
|
||
{
|
||
const QString directory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||
if (directory.isEmpty())
|
||
return;
|
||
QDir().mkpath(directory);
|
||
|
||
QFile file(QDir(directory).filePath(QStringLiteral("export.log")));
|
||
if (!file.open(QIODevice::Append | QIODevice::Text))
|
||
return;
|
||
QTextStream stream(&file);
|
||
stream << QDateTime::currentDateTime().toString(Qt::ISODate) << " " << message << "\n";
|
||
}
|
||
|
||
void MainWindow::exportPdf()
|
||
{
|
||
QString suggested = m_currentPath.isEmpty() ? tr("未命名")
|
||
: QFileInfo(m_currentPath).completeBaseName();
|
||
suggested += QStringLiteral(".pdf");
|
||
|
||
QString target = QFileDialog::getSaveFileName(
|
||
this, tr("导出 PDF"), QDir(startDirectory()).filePath(suggested),
|
||
tr("PDF 文件 (*.pdf)"), nullptr, fileDialogOptions());
|
||
if (target.isEmpty())
|
||
return;
|
||
if (!target.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive))
|
||
target += QStringLiteral(".pdf");
|
||
|
||
logExport(QStringLiteral("dialog closed, target=%1").arg(target));
|
||
statusBar()->showMessage(tr("正在导出 PDF…"));
|
||
QApplication::processEvents();
|
||
|
||
// 用 QPdfWriter 直接写 PDF,不碰任何平台的打印子系统:
|
||
// Windows 的打印后台、Linux 的 CUPS 在没有打印机或服务异常时都可能卡住。
|
||
QPdfWriter writer(target);
|
||
writer.setPageSize(QPageSize(QPageSize::A4));
|
||
writer.setPageMargins(QMarginsF(16, 16, 16, 16), QPageLayout::Millimeter);
|
||
writer.setTitle(QFileInfo(target).completeBaseName());
|
||
|
||
// 先把页面尺寸定下来再渲染:表格用的是百分比宽度,
|
||
// 页面宽度为 0 时插入表格会让某些平台的排版循环算不出结果。
|
||
QSizeF pageSize(672.76, 1001.57);
|
||
const QRectF paintRect = writer.pageLayout().paintRectPixels(writer.resolution());
|
||
const qreal resolution = qMax(1, writer.resolution());
|
||
if (paintRect.width() > 100 && paintRect.height() > 100) {
|
||
const qreal scale = 96.0 / resolution;
|
||
pageSize = QSizeF(paintRect.width() * scale, paintRect.height() * scale);
|
||
}
|
||
|
||
QTextDocument document;
|
||
document.setPageSize(pageSize);
|
||
logExport(QStringLiteral("rendering, page size=%1x%2").arg(pageSize.width()).arg(pageSize.height()));
|
||
|
||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||
MarkdownRenderer::render(m_editor->toPlainText(), &document);
|
||
const QSizeF documentSize = document.size();
|
||
logExport(QStringLiteral("rendered, blocks=%1 height=%2")
|
||
.arg(document.blockCount())
|
||
.arg(documentSize.height()));
|
||
|
||
if (!qIsFinite(documentSize.width()) || !qIsFinite(documentSize.height())
|
||
|| documentSize.height() <= 0) {
|
||
QApplication::restoreOverrideCursor();
|
||
logExport(QStringLiteral("aborted: bad document size %1x%2")
|
||
.arg(documentSize.width())
|
||
.arg(documentSize.height()));
|
||
QMessageBox::warning(this, tr("导出失败"), tr("文档排版结果异常,已中止导出。"));
|
||
return;
|
||
}
|
||
|
||
document.print(&writer);
|
||
QApplication::restoreOverrideCursor();
|
||
|
||
logExport(QStringLiteral("printed, size=%1").arg(QFileInfo(target).size()));
|
||
|
||
if (!QFileInfo::exists(target) || QFileInfo(target).size() <= 0) {
|
||
QMessageBox::warning(this, tr("导出失败"),
|
||
tr("没能写出「%1」,换个位置再试一次。")
|
||
.arg(QDir::toNativeSeparators(target)));
|
||
return;
|
||
}
|
||
statusBar()->showMessage(tr("已导出 %1").arg(QDir::toNativeSeparators(target)), 4000);
|
||
}
|
||
|
||
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();
|
||
selectInSidebar(path);
|
||
}
|
||
|
||
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::applyLanguage(const QString &code)
|
||
{
|
||
LanguageManager::instance()->setLanguage(code);
|
||
}
|
||
|
||
void MainWindow::retranslateUi()
|
||
{
|
||
m_fileMenu->setTitle(tr("文件"));
|
||
m_editMenu->setTitle(tr("编辑"));
|
||
m_viewMenu->setTitle(tr("视图"));
|
||
m_formatMenu->setTitle(tr("格式"));
|
||
m_helpMenu->setTitle(tr("帮助"));
|
||
m_recentMenu->setTitle(tr("最近打开"));
|
||
m_themeMenu->setTitle(tr("主题"));
|
||
m_headingMenu->setTitle(tr("标题"));
|
||
m_languageMenu->setTitle(tr("语言"));
|
||
|
||
m_newAction->setText(tr("新建"));
|
||
m_openAction->setText(tr("打开…"));
|
||
m_openFolderAction->setText(tr("打开文件夹…"));
|
||
m_saveAction->setText(tr("保存"));
|
||
m_saveAsAction->setText(tr("另存为…"));
|
||
m_exportPdfAction->setText(tr("导出 PDF…"));
|
||
m_quitAction->setText(tr("退出"));
|
||
|
||
m_undoAction->setText(tr("撤销"));
|
||
m_redoAction->setText(tr("重做"));
|
||
m_cutAction->setText(tr("剪切"));
|
||
m_copyAction->setText(tr("复制"));
|
||
m_pasteAction->setText(tr("粘贴"));
|
||
m_selectAllAction->setText(tr("全选"));
|
||
|
||
m_sidebarAction->setText(tr("侧栏"));
|
||
m_sourceModeAction->setText(tr("源代码模式"));
|
||
m_wrapAction->setText(tr("自动换行"));
|
||
m_zoomInAction->setText(tr("放大"));
|
||
m_zoomOutAction->setText(tr("缩小"));
|
||
m_zoomResetAction->setText(tr("实际大小"));
|
||
|
||
m_themeSystemAction->setText(ThemeManager::modeLabel(ThemeManager::Mode::System));
|
||
m_themeLightAction->setText(ThemeManager::modeLabel(ThemeManager::Mode::Light));
|
||
m_themeDarkAction->setText(ThemeManager::modeLabel(ThemeManager::Mode::Dark));
|
||
m_themeScheduleAction->setText(ThemeManager::modeLabel(ThemeManager::Mode::Schedule));
|
||
m_themeScheduleAction->setToolTip(tr("6:00–18:00 使用浅色,其余时间使用深色"));
|
||
|
||
for (int level = 1; level <= 6; ++level) {
|
||
if (m_headingActions[level - 1])
|
||
m_headingActions[level - 1]->setText(tr("标题 %1").arg(level));
|
||
}
|
||
m_codeBlockAction->setText(tr("代码块"));
|
||
m_aboutAction->setText(tr("关于 VoiletMark"));
|
||
|
||
if (m_sidebarButton) {
|
||
m_sidebarButton->setText(tr("侧栏"));
|
||
m_sidebarButton->setToolTip(tr("显示 / 隐藏文件夹侧栏(Ctrl+B)"));
|
||
}
|
||
if (m_sourceButton)
|
||
m_sourceButton->setToolTip(tr("源代码模式:关闭语法高亮与行号(Ctrl+Shift+M)"));
|
||
|
||
const QString language = LanguageManager::instance()->language();
|
||
m_languageChineseAction->setChecked(language == QLatin1String("zh_CN"));
|
||
m_languageEnglishAction->setChecked(language == QLatin1String("en"));
|
||
|
||
// 侧栏、最近打开、欢迎文档都跟着换
|
||
m_displayedFolder.clear();
|
||
refreshRecentMenu();
|
||
refreshFolder();
|
||
|
||
if (m_currentPath.isEmpty() && !m_editor->document()->isModified()
|
||
&& m_editor->toPlainText() == m_welcomeText) {
|
||
showWelcomeDocument();
|
||
}
|
||
|
||
updateWindowTitle();
|
||
updateStatusBar();
|
||
}
|
||
|
||
void MainWindow::showAbout()
|
||
{
|
||
QMessageBox box(this);
|
||
box.setWindowTitle(tr("关于 VoiletMark"));
|
||
box.setIconPixmap(QPixmap(QStringLiteral(":/packaging/voiletmark.png"))
|
||
.scaled(72, 72, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||
box.setTextFormat(Qt::RichText);
|
||
box.setText(tr("<h3>VoiletMark %1</h3>"
|
||
"<p>一个轻量的跨平台 Markdown 编辑器。</p>"
|
||
"<p>基于 Qt %2 与 C++17 构建。</p>")
|
||
.arg(QCoreApplication::applicationVersion(),
|
||
QString::fromLatin1(qVersion())));
|
||
box.setStandardButtons(QMessageBox::Ok);
|
||
box.exec();
|
||
}
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
void MainWindow::chooseFolder()
|
||
{
|
||
const QString directory = QFileDialog::getExistingDirectory(
|
||
this, tr("打开文件夹"), startDirectory(),
|
||
QFileDialog::ShowDirsOnly | fileDialogOptions());
|
||
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();
|
||
updateGitState();
|
||
|
||
m_sidebar->setVisible(true);
|
||
m_sidebarAction->setChecked(true);
|
||
}
|
||
|
||
QStringList MainWindow::scanFolder() const
|
||
{
|
||
QStringList files;
|
||
if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir())
|
||
return files;
|
||
|
||
const QDir root(m_folderPath);
|
||
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;
|
||
});
|
||
return files;
|
||
}
|
||
|
||
void MainWindow::refreshFolder()
|
||
{
|
||
if (!m_fileList)
|
||
return;
|
||
|
||
if (m_folderPath.isEmpty() || !QFileInfo(m_folderPath).isDir()) {
|
||
if (m_fileList->count() > 0 || m_displayedFolder != m_folderPath) {
|
||
m_fileList->clear();
|
||
m_displayedFolder = m_folderPath;
|
||
m_folderLabel->setText(tr("未打开文件夹"));
|
||
m_folderLabel->setToolTip(tr("文件 → 打开文件夹…"));
|
||
}
|
||
return;
|
||
}
|
||
|
||
const QDir root(m_folderPath);
|
||
if (m_displayedFolder != m_folderPath) {
|
||
m_displayedFolder = m_folderPath;
|
||
m_fileList->clear();
|
||
m_folderLabel->setText(root.dirName().isEmpty() ? m_folderPath : root.dirName());
|
||
m_folderLabel->setToolTip(QDir::toNativeSeparators(m_folderPath));
|
||
}
|
||
|
||
const QStringList files = scanFolder();
|
||
|
||
// 和当前显示的内容一致就什么都不做,避免每 5 秒闪一下、丢失选中项。
|
||
QStringList shown;
|
||
shown.reserve(m_fileList->count());
|
||
for (int index = 0; index < m_fileList->count(); ++index) {
|
||
const QString path = m_fileList->item(index)->data(Qt::UserRole).toString();
|
||
if (!path.isEmpty())
|
||
shown.append(path);
|
||
}
|
||
if (files == shown)
|
||
return;
|
||
|
||
const QString selectedPath = m_currentPath;
|
||
m_fileList->clear();
|
||
|
||
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(selectedPath);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Git
|
||
|
||
void MainWindow::updateGitState()
|
||
{
|
||
m_gitRoot.clear();
|
||
m_gitBranch.clear();
|
||
|
||
if (!m_folderPath.isEmpty() && QFileInfo(m_folderPath).isDir()) {
|
||
QProcess process;
|
||
process.setWorkingDirectory(m_folderPath);
|
||
process.start(QStringLiteral("git"),
|
||
{QStringLiteral("rev-parse"), QStringLiteral("--show-toplevel")});
|
||
if (process.waitForFinished(3000) && process.exitCode() == 0)
|
||
m_gitRoot = QString::fromLocal8Bit(process.readAllStandardOutput()).trimmed();
|
||
}
|
||
|
||
if (!m_gitRoot.isEmpty()) {
|
||
QProcess branch;
|
||
branch.setWorkingDirectory(m_gitRoot);
|
||
branch.start(QStringLiteral("git"), {QStringLiteral("rev-parse"),
|
||
QStringLiteral("--abbrev-ref"),
|
||
QStringLiteral("HEAD")});
|
||
if (branch.waitForFinished(3000) && branch.exitCode() == 0)
|
||
m_gitBranch = QString::fromLocal8Bit(branch.readAllStandardOutput()).trimmed();
|
||
}
|
||
|
||
if (m_git)
|
||
m_git->setWorkingDirectory(m_gitRoot.isEmpty() ? m_folderPath : m_gitRoot);
|
||
|
||
if (m_gitBar) {
|
||
m_gitBar->setVisible(!m_gitRoot.isEmpty() && m_sidebarMode == 0);
|
||
m_gitStatusLabel->setText(m_gitBranch.isEmpty() ? tr("Git 仓库") : m_gitBranch);
|
||
m_gitStatusLabel->setToolTip(m_gitRoot);
|
||
}
|
||
}
|
||
|
||
void MainWindow::setGitBusy(bool busy)
|
||
{
|
||
m_gitBusy = busy;
|
||
const QList<QPushButton *> buttons = {m_gitPullButton, m_gitFetchButton, m_gitPushButton,
|
||
m_gitStageButton, m_gitCommitButton};
|
||
for (QPushButton *button : buttons) {
|
||
if (button)
|
||
button->setEnabled(!busy);
|
||
}
|
||
}
|
||
|
||
void MainWindow::enqueueGit(const QStringList &arguments, const QString &label)
|
||
{
|
||
GitStep step;
|
||
step.arguments = arguments;
|
||
step.label = label;
|
||
m_gitQueue.enqueue(step);
|
||
if (!m_gitBusy)
|
||
runNextGit();
|
||
}
|
||
|
||
void MainWindow::runNextGit()
|
||
{
|
||
if (m_gitQueue.isEmpty()) {
|
||
setGitBusy(false);
|
||
statusBar()->clearMessage();
|
||
return;
|
||
}
|
||
|
||
const GitStep step = m_gitQueue.dequeue();
|
||
setGitBusy(true);
|
||
statusBar()->showMessage(tr("正在%1…").arg(step.label));
|
||
m_git->run(step.arguments, step.label);
|
||
}
|
||
|
||
void MainWindow::onGitFinished(bool ok, const QString &label, const QString &output)
|
||
{
|
||
if (!ok) {
|
||
m_gitQueue.clear();
|
||
setGitBusy(false);
|
||
statusBar()->clearMessage();
|
||
QMessageBox::warning(this, tr("Git 操作失败"),
|
||
tr("%1失败:\n\n%2")
|
||
.arg(label, output.isEmpty() ? tr("(没有输出)") : output));
|
||
return;
|
||
}
|
||
|
||
if (m_gitQueue.isEmpty()) {
|
||
setGitBusy(false);
|
||
statusBar()->showMessage(tr("%1完成").arg(label), 3000);
|
||
refreshFolder();
|
||
} else {
|
||
runNextGit();
|
||
}
|
||
}
|
||
|
||
void MainWindow::switchSidebarMode(int mode)
|
||
{
|
||
m_sidebarMode = qBound(0, mode, 1);
|
||
|
||
if (m_sidebarStack)
|
||
m_sidebarStack->setCurrentIndex(m_sidebarMode);
|
||
if (m_fileModeButton && m_tocModeButton) {
|
||
m_fileModeButton->setChecked(m_sidebarMode == 0);
|
||
m_tocModeButton->setChecked(m_sidebarMode == 1);
|
||
}
|
||
if (m_folderLabel)
|
||
m_folderLabel->setVisible(m_sidebarMode == 0);
|
||
if (m_gitBar)
|
||
m_gitBar->setVisible(m_sidebarMode == 0 && !m_gitRoot.isEmpty());
|
||
|
||
if (m_sidebarMode == 1)
|
||
rebuildToc();
|
||
|
||
QSettings().setValue(QStringLiteral("window/sidebarMode"), m_sidebarMode);
|
||
}
|
||
|
||
void MainWindow::rebuildToc()
|
||
{
|
||
if (!m_tocList || !m_editor)
|
||
return;
|
||
|
||
m_tocList->clear();
|
||
|
||
static const QRegularExpression headingPattern(
|
||
QStringLiteral(R"(^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$)"));
|
||
static const QRegularExpression fencePattern(
|
||
QStringLiteral("^\\s{0,3}(?:\\x60\\x60\\x60|~~~)"));
|
||
|
||
bool inFence = false;
|
||
QTextBlock block = m_editor->document()->firstBlock();
|
||
while (block.isValid()) {
|
||
const QString text = block.text();
|
||
|
||
if (fencePattern.match(text).hasMatch()) {
|
||
inFence = !inFence;
|
||
block = block.next();
|
||
continue;
|
||
}
|
||
|
||
if (!inFence) {
|
||
const QRegularExpressionMatch match = headingPattern.match(text);
|
||
if (match.hasMatch()) {
|
||
const int level = match.capturedLength(1);
|
||
QListWidgetItem *item = new QListWidgetItem(
|
||
QString(level - 1, QChar(0x2003)) + match.captured(2).trimmed(), m_tocList);
|
||
item->setData(Qt::UserRole, block.blockNumber());
|
||
item->setToolTip(match.captured(2).trimmed());
|
||
if (level <= 2) {
|
||
QFont font = item->font();
|
||
font.setBold(true);
|
||
item->setFont(font);
|
||
} else {
|
||
QFont font = item->font();
|
||
font.setPointSizeF(qMax(7.0, font.pointSizeF() - 0.5));
|
||
item->setFont(font);
|
||
}
|
||
}
|
||
}
|
||
|
||
block = block.next();
|
||
}
|
||
|
||
if (m_tocList->count() == 0) {
|
||
QListWidgetItem *empty = new QListWidgetItem(tr("(没有标题)"), m_tocList);
|
||
empty->setFlags(Qt::NoItemFlags);
|
||
}
|
||
}
|
||
|
||
void MainWindow::goToTocItem(QListWidgetItem *item)
|
||
{
|
||
if (!item)
|
||
return;
|
||
const QVariant data = item->data(Qt::UserRole);
|
||
if (!data.isValid())
|
||
return;
|
||
m_editor->goToBlock(data.toInt());
|
||
}
|
||
|
||
void MainWindow::gitPull()
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
enqueueGit({QStringLiteral("pull"), QStringLiteral("--ff-only")}, tr("拉取"));
|
||
}
|
||
|
||
void MainWindow::gitFetch()
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
enqueueGit({QStringLiteral("fetch"), QStringLiteral("--all"), QStringLiteral("--prune")},
|
||
tr("获取"));
|
||
}
|
||
|
||
void MainWindow::gitPush()
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
enqueueGit({QStringLiteral("push")}, tr("推送"));
|
||
}
|
||
|
||
void MainWindow::gitStageAll()
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
enqueueGit({QStringLiteral("add"), QStringLiteral("-A")}, tr("暂存"));
|
||
}
|
||
|
||
void MainWindow::gitCommit()
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
|
||
QProcess staged;
|
||
staged.setWorkingDirectory(m_gitRoot);
|
||
staged.start(QStringLiteral("git"), {QStringLiteral("diff"), QStringLiteral("--cached"),
|
||
QStringLiteral("--quiet")});
|
||
if (staged.waitForFinished(5000) && staged.exitCode() == 0) {
|
||
QMessageBox::information(this, tr("提交"),
|
||
tr("暂存区是空的,先点「暂存」把所有改动加进去。"));
|
||
return;
|
||
}
|
||
|
||
bool ok = false;
|
||
const QString message = QInputDialog::getMultiLineText(
|
||
this, tr("提交"), tr("提交说明:"), QString(), &ok);
|
||
if (!ok || message.trimmed().isEmpty())
|
||
return;
|
||
|
||
enqueueGit({QStringLiteral("commit"), QStringLiteral("-m"), message.trimmed()}, tr("提交"));
|
||
}
|
||
|
||
void MainWindow::gitAddFile(const QString &path)
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
const QString relative = QDir(m_gitRoot).relativeFilePath(path);
|
||
enqueueGit({QStringLiteral("add"), QStringLiteral("--"), relative}, tr("添加到提交"));
|
||
}
|
||
|
||
void MainWindow::gitIgnoreFile(const QString &path)
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
|
||
const QString relative = QDir(m_gitRoot).relativeFilePath(path);
|
||
const QString ignorePath = QDir(m_gitRoot).filePath(QStringLiteral(".gitignore"));
|
||
|
||
QStringList lines;
|
||
QFile ignoreFile(ignorePath);
|
||
if (ignoreFile.open(QIODevice::ReadOnly | QIODevice::Text))
|
||
lines = QString::fromUtf8(ignoreFile.readAll()).split(QLatin1Char('\n'));
|
||
ignoreFile.close();
|
||
|
||
for (const QString &line : lines) {
|
||
if (line.trimmed() == relative) {
|
||
statusBar()->showMessage(tr("「%1」已经在 .gitignore 里了").arg(relative), 4000);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!ignoreFile.open(QIODevice::Append | QIODevice::Text)) {
|
||
QMessageBox::warning(this, tr("忽略文件"),
|
||
tr("打不开 .gitignore:%1").arg(ignoreFile.errorString()));
|
||
return;
|
||
}
|
||
QTextStream stream(&ignoreFile);
|
||
stream << relative << "\n";
|
||
ignoreFile.close();
|
||
|
||
statusBar()->showMessage(tr("已把 %1 写进 .gitignore").arg(relative), 4000);
|
||
}
|
||
|
||
void MainWindow::gitRemoveRemote(const QString &path)
|
||
{
|
||
if (m_gitRoot.isEmpty())
|
||
return;
|
||
|
||
const QString relative = QDir(m_gitRoot).relativeFilePath(path);
|
||
|
||
QProcess check;
|
||
check.setWorkingDirectory(m_gitRoot);
|
||
check.start(QStringLiteral("git"), {QStringLiteral("ls-files"), QStringLiteral("--error-unmatch"),
|
||
QStringLiteral("--"), relative});
|
||
if (!(check.waitForFinished(5000) && check.exitCode() == 0)) {
|
||
QMessageBox::information(this, tr("删除远端"),
|
||
tr("「%1」没有被 git 跟踪,远端本来就没有它。").arg(relative));
|
||
return;
|
||
}
|
||
|
||
QMessageBox box(QMessageBox::Warning, tr("删除远端"),
|
||
tr("从仓库删除「%1」并推送到远端?\n\n"
|
||
"本地文件会保留,远端和别人 pull 下来的仓库里会删掉它。")
|
||
.arg(relative),
|
||
QMessageBox::NoButton, this);
|
||
QPushButton *confirm = box.addButton(tr("删除并推送"), QMessageBox::AcceptRole);
|
||
box.addButton(tr("取消"), QMessageBox::RejectRole);
|
||
box.exec();
|
||
if (box.clickedButton() != confirm)
|
||
return;
|
||
|
||
enqueueGit({QStringLiteral("rm"), QStringLiteral("--cached"), QStringLiteral("--"), relative},
|
||
tr("从仓库移除"));
|
||
enqueueGit({QStringLiteral("commit"), QStringLiteral("-m"),
|
||
tr("chore: 从仓库删除 %1").arg(relative)},
|
||
tr("提交"));
|
||
enqueueGit({QStringLiteral("push")}, tr("推送"));
|
||
}
|
||
|
||
void MainWindow::showFileContextMenu(const QPoint &pos)
|
||
{
|
||
QListWidgetItem *item = m_fileList->itemAt(pos);
|
||
if (!item)
|
||
return;
|
||
const QString path = item->data(Qt::UserRole).toString();
|
||
if (path.isEmpty())
|
||
return;
|
||
|
||
QMenu menu(this);
|
||
menu.addAction(tr("打开"), this, [this, path] { openPath(path); });
|
||
|
||
if (!m_gitRoot.isEmpty()) {
|
||
menu.addSeparator();
|
||
QAction *addAction = menu.addAction(tr("添加"), this, [this, path] { gitAddFile(path); });
|
||
addAction->setToolTip(tr("git add 这个文件"));
|
||
QAction *ignoreAction = menu.addAction(tr("忽略"), this, [this, path] { gitIgnoreFile(path); });
|
||
ignoreAction->setToolTip(tr("把路径写进 .gitignore(不改仓库里已跟踪的文件)"));
|
||
QAction *removeAction =
|
||
menu.addAction(tr("删除远端"), this, [this, path] { gitRemoveRemote(path); });
|
||
removeAction->setToolTip(tr("git rm --cached 后提交并推送:远端删除,本地保留"));
|
||
}
|
||
|
||
menu.exec(m_fileList->viewport()->mapToGlobal(pos));
|
||
}
|
||
|