diff --git a/README.md b/README.md index eacb211..95205e4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.1.0**:先把编辑器本体做扎实——干净的界面、顺手的编辑手感、 -以及**自动跟随系统**的浅色 / 深色主题。 +当前版本 **0.2.0**。 ## 界面 @@ -11,28 +10,55 @@ | --- | --- | | ![浅色主题](docs/screenshot-light.png) | ![深色主题](docs/screenshot-dark.png) | +左侧是文件夹侧栏,递归列出目录里的所有 Markdown 文件,点一下就切换。 + ## 功能 ### 编辑 - Markdown 语法高亮:标题、粗体 / 斜体 / 删除线、行内代码、围栏代码块、 链接与图片、引用、有序 / 无序列表、任务列表、分隔线 +- `Ctrl+1` ~ `Ctrl+6` 把选中行设成 1–6 级标题,再按一次变回正文 +- `Ctrl+/` 把选中内容包成围栏代码块,再按一次拆掉 - 行号栏、当前行高亮 - `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进 - 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号 - 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显 - 把 Markdown 文件直接拖进窗口即可打开 +### 文件夹侧栏 + +- **文件 → 打开文件夹…**(`Ctrl+Shift+O`)选一个目录,递归扫描 + `*.md` / `*.markdown` / `*.mdown` / `*.mkd` +- `Ctrl+B` 显示 / 隐藏侧栏 +- 点文件名即打开,当前文件在列表里高亮;右上角「刷新」重新扫描 +- 上次打开的文件夹会被记住,下次启动自动恢复 + +### 源代码模式 + +左下角状态栏的 **``** 按钮(或 `Ctrl+Shift+M`)切到源代码模式: +关掉语法高亮、行号与当前行高亮,只显示文件原始内容,适合核对格式或复制纯文本。 +默认是编辑模式,按钮会显示成 `` 表示当前处于源码模式。 + ### 文件 - 新建、打开、保存、另存为、最近打开(最多 8 条) - 保留原文件的 **BOM** 与 **CRLF** 换行风格,保存时原子写入(`QSaveFile`) - 关闭或切换文件时提示未保存的修改 -- 记忆窗口位置、字号、换行开关等设置 +- 记忆窗口位置、字号、换行开关、侧栏状态等设置 ### 主题 -界面默认**跟随系统**自动在浅色 / 深色之间切换,每 2 秒检测一次: +四种模式: + +| 模式 | 说明 | +| --- | --- | +| 跟随系统(默认) | 读取桌面环境的浅色 / 深色设置 | +| 浅色 | 固定浅色 | +| 深色 | 固定深色 | +| 按时段自动 | 6:00–18:00 浅色,其余时间深色 | + +跟随系统时每 2 秒检测一次,改动后整套界面立即重绘: | 平台 | 检测方式 | | --- | --- | @@ -40,7 +66,7 @@ | macOS | `defaults read -g AppleInterfaceStyle` | | Linux | gsettings `color-scheme` / GTK 主题名 → KDE `kdeglobals` → XFCE `xsettings.xml` → 调色板兜底 | -也可以在 **视图 → 主题** 中手动锁定「跟随系统 / 浅色 / 深色」,选择会被记住。 +模式可在 **视图 → 主题** 中切换,选择会被记住。 ## 构建 @@ -77,7 +103,7 @@ make -j"$(nproc)" ```bash VoiletMark # 打开编辑器(显示欢迎文档) VoiletMark notes.md # 直接打开指定文件 -VoiletMark --theme dark # 本次运行使用深色主题 +VoiletMark --theme dark # 本次运行使用深色主题(system / light / dark / schedule) VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉回归) ``` @@ -87,6 +113,11 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉 | --- | --- | | `Ctrl+N` / `Ctrl+O` / `Ctrl+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+滚轮`、`Ctrl++`、`Ctrl+-`、`Ctrl+0` | 缩放 / 恢复字号 | | `Alt+Z` | 切换自动换行 | @@ -98,9 +129,9 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉 ``` src/ ├── main.cpp 入口:命令行解析、Fusion 样式、主题初始化 -├── ThemeManager.{h,cpp} 系统主题检测 + 调色板 / QSS 生成 -├── MainWindow.{h,cpp} 菜单、文件读写、最近打开、状态栏、设置 -├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、拖放、缩放 +├── ThemeManager.{h,cpp} 四种主题模式 + 系统检测 + 调色板 / QSS 生成 +├── MainWindow.{h,cpp} 菜单、文件读写、文件夹侧栏、状态栏、设置 +├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、标题 / 代码块命令、源码模式 └── MarkdownHighlighter.{h,cpp} Markdown 语法着色(含围栏代码块状态机) ``` diff --git a/VoiletMark.pro b/VoiletMark.pro index a1bfbc6..7638386 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,6 +1,6 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.1.0 +VERSION = 0.2.0 QT += core gui widgets diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png index e4055cf..64a947d 100644 Binary files a/docs/screenshot-dark.png and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png index 53a3c6f..a35150e 100644 Binary files a/docs/screenshot-light.png and b/docs/screenshot-light.png differ diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 3243d95..c9f6900 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -8,21 +8,32 @@ #include #include #include +#include +#include #include #include #include +#include +#include #include +#include #include #include #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include + +#include namespace { @@ -82,6 +93,7 @@ MainWindow::MainWindow(QWidget *parent) setCentralWidget(m_editor); createActions(); + createSidebar(); createMenus(); createStatusBar(); loadSettings(); @@ -169,6 +181,79 @@ void MainWindow::createActions() 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); + 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() @@ -176,6 +261,7 @@ void MainWindow::createMenus() QMenu *fileMenu = menuBar()->addMenu(tr("文件")); fileMenu->addAction(m_newAction); fileMenu->addAction(m_openAction); + fileMenu->addAction(m_openFolderAction); m_recentMenu = fileMenu->addMenu(tr("最近打开")); fileMenu->addSeparator(); fileMenu->addAction(m_saveAction); @@ -194,6 +280,9 @@ void MainWindow::createMenus() editMenu->addAction(m_selectAllAction); QMenu *viewMenu = menuBar()->addMenu(tr("视图")); + viewMenu->addAction(m_sidebarAction); + viewMenu->addAction(m_sourceModeAction); + viewMenu->addSeparator(); viewMenu->addAction(m_wrapAction); viewMenu->addSeparator(); viewMenu->addAction(m_zoomInAction); @@ -208,7 +297,10 @@ void MainWindow::createMenus() m_themeSystemAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::System), this); m_themeLightAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Light), this); m_themeDarkAction = new QAction(ThemeManager::modeLabel(ThemeManager::Mode::Dark), this); - for (QAction *action : {m_themeSystemAction, m_themeLightAction, m_themeDarkAction}) { + 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); themeMenu->addAction(action); @@ -221,6 +313,16 @@ void MainWindow::createMenus() [this] { applyThemeMode(static_cast(ThemeManager::Mode::Light)); }); connect(m_themeDarkAction, &QAction::triggered, this, [this] { applyThemeMode(static_cast(ThemeManager::Mode::Dark)); }); + connect(m_themeScheduleAction, &QAction::triggered, this, + [this] { applyThemeMode(static_cast(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("帮助")); helpMenu->addAction(tr("关于 VoiletMark"), this, &MainWindow::showAbout); @@ -228,10 +330,20 @@ void MainWindow::createMenus() void MainWindow::createStatusBar() { + // 左下角的 开关:切到源代码模式。 + m_sourceButton = new QToolButton(this); + m_sourceButton->setText(QStringLiteral("")); + 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() @@ -259,6 +371,21 @@ void MainWindow::loadSettings() 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() @@ -269,6 +396,8 @@ void MainWindow::saveSettings() 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) @@ -455,6 +584,7 @@ void MainWindow::setCurrentFile(const QString &path) setWindowFilePath(QString()); } updateWindowTitle(); + selectInSidebar(path); } void MainWindow::updateWindowTitle() @@ -565,3 +695,133 @@ void MainWindow::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("") : QStringLiteral("")); + } + if (m_sourceModeAction) { + const QSignalBlocker blocker(m_sourceModeAction); + m_sourceModeAction->setChecked(enabled); + } + + QSettings().setValue(QStringLiteral("editor/sourceMode"), enabled); +} diff --git a/src/MainWindow.h b/src/MainWindow.h index 4633ed9..e5091bd 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -5,9 +5,13 @@ class QAction; class QActionGroup; +class QDockWidget; class QLabel; +class QListWidget; +class QListWidgetItem; class QMenu; class QTextDocument; +class QToolButton; class MarkdownEditor; class MainWindow : public QMainWindow @@ -26,6 +30,7 @@ private: enum class Eol { Lf, Crlf }; void createActions(); + void createSidebar(); void createMenus(); void createStatusBar(); void loadSettings(); @@ -44,6 +49,13 @@ private: void refreshRecentMenu(); 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 resetZoom(); void setWordWrapEnabled(bool enabled); @@ -60,6 +72,12 @@ private: MarkdownEditor *m_editor = 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; Eol m_eol = Eol::Lf; bool m_hasBom = false; @@ -87,6 +105,13 @@ private: QAction *m_themeSystemAction = nullptr; QAction *m_themeLightAction = 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; }; diff --git a/src/MarkdownEditor.cpp b/src/MarkdownEditor.cpp index e396ddb..555a4ac 100644 --- a/src/MarkdownEditor.cpp +++ b/src/MarkdownEditor.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -21,6 +23,17 @@ namespace { 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 { public: @@ -150,7 +163,7 @@ void MarkdownEditor::applyEditorFont() 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) @@ -166,6 +179,11 @@ void MarkdownEditor::updateLineNumberArea(const QRect &rect, int dy) void MarkdownEditor::updateCurrentLineHighlight() { + if (m_sourceMode) { + setExtraSelections({}); + return; + } + QTextEdit::ExtraSelection selection; selection.format.setBackground(ThemeManager::instance()->colors().currentLine); selection.format.setProperty(QTextFormat::FullWidthSelection, true); @@ -181,6 +199,206 @@ void MarkdownEditor::applyTheme() 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) { QPlainTextEdit::resizeEvent(event); diff --git a/src/MarkdownEditor.h b/src/MarkdownEditor.h index 964d2a1..51d2996 100644 --- a/src/MarkdownEditor.h +++ b/src/MarkdownEditor.h @@ -3,6 +3,7 @@ #include class MarkdownHighlighter; +class QTextBlock; /// 编辑器本体:等宽字体、行号栏、当前行高亮、Tab 缩进、 /// Ctrl+滚轮缩放,以及把拖入的文件转交给主窗口打开。 @@ -21,9 +22,19 @@ public: 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: void zoomStepRequested(int direction); void fileDropped(const QString &path); + void sourceModeChanged(bool enabled); protected: void resizeEvent(QResizeEvent *event) override; @@ -43,8 +54,13 @@ private: void applyEditorFont(); void indentSelection(bool outdent); 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; MarkdownHighlighter *m_highlighter = nullptr; int m_fontPointSize = 12; + bool m_sourceMode = false; }; diff --git a/src/MarkdownHighlighter.cpp b/src/MarkdownHighlighter.cpp index 4ac4206..4b6fa82 100644 --- a/src/MarkdownHighlighter.cpp +++ b/src/MarkdownHighlighter.cpp @@ -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) { setCurrentBlockState(StateNormal); - if (text.isEmpty()) + if (!m_enabled || text.isEmpty()) return; static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)")); diff --git a/src/MarkdownHighlighter.h b/src/MarkdownHighlighter.h index f2e3e63..5d3d2fb 100644 --- a/src/MarkdownHighlighter.h +++ b/src/MarkdownHighlighter.h @@ -16,6 +16,9 @@ class MarkdownHighlighter : public QSyntaxHighlighter public: explicit MarkdownHighlighter(QTextDocument *document = nullptr); + /// 源代码模式会关掉着色,但不改文档内容(rehighlight 会清掉旧格式)。 + void setHighlightingEnabled(bool enabled); + protected: void highlightBlock(const QString &text) override; @@ -38,4 +41,5 @@ private: QTextCharFormat m_ruleFormat; QTextCharFormat m_taskDoneFormat; QTextCharFormat m_strikeFormat; + bool m_enabled = true; }; diff --git a/src/ThemeManager.cpp b/src/ThemeManager.cpp index 1680ca5..e752855 100644 --- a/src/ThemeManager.cpp +++ b/src/ThemeManager.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -76,6 +77,8 @@ ThemeColors lightColors() c.menuBackground = hex("#ffffff"); c.scrollHandle = hex("#c9ced6"); c.scrollHandleHover = hex("#aeb5bf"); + c.sidebarBackground = hex("#f8f9fb"); + c.hoverBackground = hex("#eaedf1"); c.editorBase = hex("#ffffff"); c.editorText = hex("#24292f"); @@ -119,6 +122,8 @@ ThemeColors darkColors() c.menuBackground = hex("#242730"); c.scrollHandle = hex("#3a3f47"); c.scrollHandleHover = hex("#4d535d"); + c.sidebarBackground = hex("#1a1c20"); + c.hoverBackground = hex("#272b31"); c.editorBase = hex("#1e2024"); c.editorText = hex("#d7dade"); @@ -171,10 +176,7 @@ void ThemeManager::initialize(Mode forcedMode, bool persist) } else { const QString saved = QSettings().value(QStringLiteral("theme/mode")).toString(); m_mode = modeFromString(saved, nullptr); - const Scheme scheme = (m_mode == Mode::Dark) ? Scheme::Dark - : (m_mode == Mode::Light) ? Scheme::Light - : detectSystemScheme(); - applyScheme(scheme, true); + applyScheme(schemeForMode(m_mode), true); } m_pollTimer->start(); } @@ -185,20 +187,35 @@ void ThemeManager::setMode(Mode mode, bool persist) if (persist) QSettings().setValue(QStringLiteral("theme/mode"), modeToString(mode)); - if (m_mode == Mode::System) - applyScheme(detectSystemScheme(), true); - else - applyScheme(m_mode == Mode::Dark ? Scheme::Dark : Scheme::Light, true); + applyScheme(schemeForMode(m_mode), true); } void ThemeManager::pollSystemTheme() { - if (m_mode != Mode::System) + if (m_mode == Mode::Light || m_mode == Mode::Dark) return; - const Scheme detected = detectSystemScheme(); - if (detected != m_scheme) - applyScheme(detected, false); + // 跟随系统:读桌面设置;按时段:到 6:00 / 18:00 自动翻转。 + const Scheme target = schemeForMode(m_mode); + 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) @@ -276,6 +293,16 @@ QPushButton { background-color: @button; color: @buttonText; border: 1px solid @ QPushButton:hover { background-color: @highlight; color: @highlightedText; } QPushButton:default { background-color: @accent; color: #ffffff; border-color: @accent; } QToolTip { background-color: @menuBackground; color: @text; border: 1px solid @border; padding: 4px; } +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 这类前缀冲突。 @@ -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("@disabled"), m_colors.disabled.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("@highlight"), m_colors.highlight.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) { switch (mode) { - case Mode::Light: return QStringLiteral("light"); - case Mode::Dark: return QStringLiteral("dark"); - case Mode::System: break; + case Mode::Light: return QStringLiteral("light"); + case Mode::Dark: return QStringLiteral("dark"); + case Mode::Schedule: return QStringLiteral("schedule"); + case Mode::System: break; } return QStringLiteral("system"); } @@ -311,8 +341,9 @@ QString ThemeManager::modeToString(Mode mode) ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok) { const QString normalized = value.trimmed().toLower(); - if (normalized == QLatin1String("light")) { if (ok) *ok = true; return Mode::Light; } - if (normalized == QLatin1String("dark")) { if (ok) *ok = true; return Mode::Dark; } + if (normalized == QLatin1String("light")) { if (ok) *ok = true; return Mode::Light; } + 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()); return Mode::System; } @@ -320,9 +351,10 @@ ThemeManager::Mode ThemeManager::modeFromString(const QString &value, bool *ok) QString ThemeManager::modeLabel(Mode mode) { switch (mode) { - case Mode::Light: return QObject::tr("浅色"); - case Mode::Dark: return QObject::tr("深色"); - case Mode::System: break; + case Mode::Light: return QObject::tr("浅色"); + case Mode::Dark: return QObject::tr("深色"); + case Mode::Schedule: return QObject::tr("按时段自动"); + case Mode::System: break; } return QObject::tr("跟随系统"); } diff --git a/src/ThemeManager.h b/src/ThemeManager.h index 5463043..b72a34a 100644 --- a/src/ThemeManager.h +++ b/src/ThemeManager.h @@ -26,6 +26,8 @@ struct ThemeColors QColor menuBackground; QColor scrollHandle; QColor scrollHandleHover; + QColor sidebarBackground; + QColor hoverBackground; // 编辑器 QColor editorBase; @@ -53,16 +55,17 @@ struct ThemeColors /// 负责“跟随系统自动切换浅色 / 深色主题”。 /// -/// 检测顺序:显式覆盖(环境变量 / 命令行)→ 平台自身设置 +/// 四种模式:跟随系统 / 浅色 / 深色 / 按时段自动(6:00 起浅色、18:00 起深色)。 +/// 跟随系统时的检测顺序:显式覆盖(环境变量 / 命令行)→ 平台自身设置 /// (Windows 注册表、macOS defaults、Linux 的 KDE / GTK / XFCE / gsettings) /// → 启动时的调色板亮度兜底。 -/// 每 2 秒轮询一次系统设置,变化后立即重绘整个界面。 +/// 每 2 秒轮询一次,变化后立即重绘整个界面。 class ThemeManager : public QObject { Q_OBJECT public: - enum class Mode { System, Light, Dark }; + enum class Mode { System, Light, Dark, Schedule }; enum class Scheme { Light, Dark }; static ThemeManager *instance(); @@ -95,6 +98,9 @@ private: QString styleSheet() const; Scheme detectSystemScheme() const; + /// 6:00–18:00 浅色,其余时间深色。 + Scheme scheduleScheme() const; + Scheme schemeForMode(Mode mode) const; Mode m_mode = Mode::System; Scheme m_scheme = Scheme::Light;