Files
VoiletMark/src/MainWindow.cpp
T
liyepan 5d1fb0fab7 feat: v0.6.0 —— 选中语言(中/英)即时切换,导出不再有预览窗口
导出
- 去掉导出预览对话框:Ctrl+E 直接弹保存对话框,选路径即导出
- 导出失败才弹提示,成功只在状态栏显示一行

多语言
- 新增 LanguageManager:中文(zh_CN)/ 英文(en),视图 → 语言 切换,
  立即生效并记住选择
- 英文翻译由 translations/voiletmark_en.ts(69 条)编译成 .qm 内嵌
- 打包 Qt 官方的 qtbase_zh_CN.qm,文件对话框 / 消息框的按钮也跟着变中文
- 欢迎文档随语言切换(新增英文版)
- retranslateUi 负责菜单、动作、状态栏、侧栏文案的即时刷新

其它
- 删除 ExportDialog 与对应截图,README 同步
2026-10-22 18:07:31 +08:00

1065 lines
37 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "MainWindow.h"
#include "LanguageManager.h"
#include "MarkdownEditor.h"
#include "MarkdownRenderer.h"
#include "ThemeManager.h"
#include <QAction>
#include <QActionGroup>
#include <QApplication>
#include <QCloseEvent>
#include <QDir>
#include <QDirIterator>
#include <QDockWidget>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QPageLayout>
#include <QPageSize>
#include <QPixmap>
#include <QPrinter>
#include <QSaveFile>
#include <QSettings>
#include <QSignalBlocker>
#include <QStandardPaths>
#include <QStatusBar>
#include <QTextBlock>
#include <QTextCursor>
#include <QTextDocument>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include <algorithm>
namespace {
constexpr int kMaxRecentFiles = 8;
constexpr int kDefaultFontSize = 12;
const char kWelcomeText[] = R"VOILETMARK(
# 欢迎使用 VoiletMark
一款轻量的跨平台 Markdown 编辑器,基于 **Qt 5** 与 C++ 构建。
## 快速开始
- `Ctrl+O` 打开文件,`Ctrl+S` 保存,`Ctrl+Shift+S` 另存为
- 也可以直接把 Markdown 文件拖进窗口
- `Tab` / `Shift+Tab` 缩进整行,`Ctrl+滚轮` 调整字号
## 主题
界面默认跟随系统的浅色 / 深色设置自动切换,
也可以在 **视图 → 主题** 中手动锁定。
## 语法预览
> 引用、列表、`行内代码`、**粗体**、*斜体*、~~删除线~~
> 以及 [链接](https://example.com) 都会实时着色。
1. 有序列表项
2. 另一个有序列表项
- [x] 自动深浅色主题
- [x] Markdown 语法高亮
- [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.
- [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);
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);
m_folderLabel = new QLabel(tr("未打开文件夹"), content);
m_folderLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
layout->addWidget(m_folderLabel);
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(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);
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()
{
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);;所有文件 (*)"));
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);;所有文件 (*)"));
if (path.isEmpty())
return false;
if (QFileInfo(path).suffix().isEmpty())
path += QStringLiteral(".md");
return writeToFile(path);
}
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)"));
if (target.isEmpty())
return;
if (!target.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive))
target += QStringLiteral(".pdf");
QTextDocument document;
MarkdownRenderer::render(m_editor->toPlainText(), &document);
document.setPageSize(QSizeF(672.76, 1001.57));
QPrinter printer(QPrinter::HighResolution);
printer.setOutputFormat(QPrinter::PdfFormat);
printer.setOutputFileName(target);
printer.setPageSize(QPageSize(QPageSize::A4));
printer.setPageMargins(QMarginsF(16, 16, 16, 16), QPageLayout::Millimeter);
printer.setDocName(QFileInfo(target).completeBaseName());
document.print(&printer);
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);
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);
}
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);
}