feat: v0.8.1 —— 代码框左上角加语言下拉菜单

- 编辑模式下每个代码框左上角显示当前语言(纯文本 / C++ / Python …),
  点开是一个带勾选标记的语言菜单
- 选中后改写围栏上的语言标记(例如 ```cpp → ```python),
  高亮立即跟着变;选「纯文本」则去掉语言标记
- 下拉与复制按钮都是画在编辑器视口里的,跟着滚动重算,源码模式下都不显示
- 「纯文本」加入中英文翻译
- 截图与 README 同步
This commit is contained in:
2026-10-22 18:16:14 +08:00
parent 233d732604
commit 0b32ccaaf3
10 changed files with 199 additions and 29 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
当前版本 **0.8.0**。 当前版本 **0.8.1**。
## 界面 ## 界面
@@ -29,6 +29,8 @@
- 代码块按语言做语法高亮:C / C++ / Python / JavaScript / TypeScript / - 代码块按语言做语法高亮:C / C++ / Python / JavaScript / TypeScript /
Java / Rust / Go / JSON / Bash / SQL / YAML,关键字、字符串、数字、 Java / Rust / Go / JSON / Bash / SQL / YAML,关键字、字符串、数字、
注释、预处理指令分色;不认识的标记语言就按等宽纯文本显示 注释、预处理指令分色;不认识的标记语言就按等宽纯文本显示
- 代码框**左上角**是语言下拉菜单,选一下就会改写围栏上的语言标记,
高亮立刻跟着变(选「纯文本」则去掉语言)
- `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进 - `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进
- 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号 - 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号
- 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显 - 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显
+1 -1
View File
@@ -1,6 +1,6 @@
TEMPLATE = app TEMPLATE = app
TARGET = VoiletMark TARGET = VoiletMark
VERSION = 0.8.0 VERSION = 0.8.1
QT += core gui widgets printsupport QT += core gui widgets printsupport
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+28
View File
@@ -163,6 +163,34 @@ const LanguageDef *definition(Language language)
} // namespace } // namespace
const QVector<LanguageEntry> &languageEntries()
{
static const QVector<LanguageEntry> entries = {
{Language::C, QStringLiteral("c"), QStringLiteral("C")},
{Language::Cpp, QStringLiteral("cpp"), QStringLiteral("C++")},
{Language::Python, QStringLiteral("python"), QStringLiteral("Python")},
{Language::JavaScript, QStringLiteral("js"), QStringLiteral("JavaScript")},
{Language::TypeScript, QStringLiteral("ts"), QStringLiteral("TypeScript")},
{Language::Java, QStringLiteral("java"), QStringLiteral("Java")},
{Language::Rust, QStringLiteral("rust"), QStringLiteral("Rust")},
{Language::Go, QStringLiteral("go"), QStringLiteral("Go")},
{Language::Json, QStringLiteral("json"), QStringLiteral("JSON")},
{Language::Bash, QStringLiteral("bash"), QStringLiteral("Bash")},
{Language::Sql, QStringLiteral("sql"), QStringLiteral("SQL")},
{Language::Yaml, QStringLiteral("yaml"), QStringLiteral("YAML")},
};
return entries;
}
QString displayName(Language language)
{
for (const LanguageEntry &entry : languageEntries()) {
if (entry.language == language)
return entry.name;
}
return QString();
}
Language fromInfoString(const QString &info) Language fromInfoString(const QString &info)
{ {
QString name = info.trimmed().toLower(); QString name = info.trimmed().toLower();
+11
View File
@@ -34,6 +34,17 @@ struct Span {
Token token = Token::Keyword; Token token = Token::Keyword;
}; };
struct LanguageEntry {
Language language;
QString info; // 写进围栏里的标记,例如 cpp
QString name; // 显示名,例如 C++
};
/// 供语言下拉菜单使用(顺序即菜单顺序)。
const QVector<LanguageEntry> &languageEntries();
QString displayName(Language language);
/// 从 ```cpp 这类 info string 里识别语言(大小写不敏感)。 /// 从 ```cpp 这类 info string 里识别语言(大小写不敏感)。
Language fromInfoString(const QString &info); Language fromInfoString(const QString &info);
+133 -19
View File
@@ -12,6 +12,7 @@
#include <QFontMetricsF> #include <QFontMetricsF>
#include <QGuiApplication> #include <QGuiApplication>
#include <QKeyEvent> #include <QKeyEvent>
#include <QMenu>
#include <QMouseEvent> #include <QMouseEvent>
#include <QMimeData> #include <QMimeData>
#include <QPainter> #include <QPainter>
@@ -56,6 +57,14 @@ bool isFenceLine(const QTextBlock &block)
return block.isValid() && block.text().trimmed().startsWith(fenceMarker()); return block.isValid() && block.text().trimmed().startsWith(fenceMarker());
} }
QString fenceInfo(const QString &line)
{
QString info = line.trimmed();
static const QRegularExpression marker(QStringLiteral("^(?:```+|~~~+)"));
info.remove(marker);
return info.trimmed();
}
class LineNumberArea : public QWidget class LineNumberArea : public QWidget
{ {
public: public:
@@ -519,10 +528,23 @@ void MarkdownEditor::paintEvent(QPaintEvent *event)
return; return;
} }
// 编辑模式:代码块套一个圆角框,右上角给个复制按钮。 // 编辑模式:代码块套圆角框,左上角选语言,右上角复制。
const ThemeColors &colors = ThemeManager::instance()->colors(); const ThemeColors &colors = ThemeManager::instance()->colors();
const QRectF visible(event->rect()); const QRectF visible(event->rect());
auto drawBadge = [&](const QRectF &badge, const QString &label, bool hovered, bool emphasized) {
if (!badge.isValid())
return;
QFont small = font();
small.setPointSizeF(qMax(7.0, font().pointSizeF() - 2.0));
painter.setFont(small);
painter.setPen(QPen(hovered ? colors.accent : colors.border, 1));
painter.setBrush(colors.editorBase);
painter.drawRoundedRect(badge, 4, 4);
painter.setPen(emphasized ? colors.accent : colors.placeholder);
painter.drawText(badge, Qt::AlignCenter, label);
};
for (int index = 0; index < m_codeFences.size(); ++index) { for (int index = 0; index < m_codeFences.size(); ++index) {
const QRectF rect = codeFenceRect(index); const QRectF rect = codeFenceRect(index);
if (!rect.isValid() || !rect.intersects(visible)) if (!rect.isValid() || !rect.intersects(visible))
@@ -532,21 +554,12 @@ void MarkdownEditor::paintEvent(QPaintEvent *event)
painter.setPen(QPen(colors.border, 1)); painter.setPen(QPen(colors.border, 1));
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 6, 6); painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 6, 6);
const QRectF button = copyButtonRect(index); drawBadge(languageRect(index), languageLabel(index) + QStringLiteral(" \u25be"),
if (!button.isValid()) m_hoveredLanguage == index, false);
continue;
QFont small = font();
small.setPointSizeF(qMax(7.0, font().pointSizeF() - 2.0));
painter.setFont(small);
const bool hovered = (m_hoveredFence == index);
const bool copied = (m_copiedFence == index); const bool copied = (m_copiedFence == index);
painter.setPen(QPen(hovered ? colors.accent : colors.border, 1)); drawBadge(copyButtonRect(index), copied ? tr("已复制") : tr("复制"),
painter.setBrush(colors.editorBase); m_hoveredFence == index, copied);
painter.drawRoundedRect(button, 4, 4);
painter.setPen(copied ? colors.accent : colors.placeholder);
painter.drawText(button, Qt::AlignCenter, copied ? tr("已复制") : tr("复制"));
} }
} }
@@ -569,6 +582,87 @@ QRectF MarkdownEditor::codeFenceRect(int index) const
return QRectF(3, top, viewport()->width() - 6, bottom - top); return QRectF(3, top, viewport()->width() - 6, bottom - top);
} }
QRectF MarkdownEditor::languageRect(int index) const
{
const QRectF rect = codeFenceRect(index);
if (!rect.isValid())
return QRectF();
QFont small = font();
small.setPointSizeF(qMax(7.0, font().pointSizeF() - 2.0));
const QFontMetricsF metrics(small);
const QString label = languageLabel(index) + QStringLiteral(" \u25be");
const qreal width = metrics.horizontalAdvance(label) + 16;
const qreal height = metrics.height() + 6;
return QRectF(rect.left() + 8, rect.top() + 6, width, height);
}
QString MarkdownEditor::languageLabel(int index) const
{
if (index < 0 || index >= m_codeFences.size())
return QString();
const CodeHighlight::Language language = m_codeFences.at(index).language;
if (language == CodeHighlight::Language::None)
return tr("纯文本");
return CodeHighlight::displayName(language);
}
void MarkdownEditor::showLanguageMenu(int index)
{
if (index < 0 || index >= m_codeFences.size())
return;
QMenu menu(this);
QAction *plain = menu.addAction(tr("纯文本"));
plain->setData(QString());
plain->setCheckable(true);
plain->setChecked(m_codeFences.at(index).language == CodeHighlight::Language::None);
menu.addSeparator();
for (const CodeHighlight::LanguageEntry &entry : CodeHighlight::languageEntries()) {
QAction *action = menu.addAction(entry.name);
action->setData(entry.info);
action->setCheckable(true);
action->setChecked(entry.language == m_codeFences.at(index).language);
}
const QRectF badge = languageRect(index);
const QPoint origin = viewport()->mapToGlobal(badge.bottomLeft().toPoint());
QAction *chosen = menu.exec(origin);
if (!chosen)
return;
setCodeFenceLanguage(index, chosen->data().toString());
}
void MarkdownEditor::setCodeFenceLanguage(int index, const QString &info)
{
if (index < 0 || index >= m_codeFences.size())
return;
const int blockNumber = m_codeFences.at(index).open;
QTextBlock block = document()->findBlockByNumber(blockNumber);
if (!block.isValid())
return;
static const QRegularExpression marker(QStringLiteral("^\\s{0,3}(?:```+|~~~+)"));
const QRegularExpressionMatch match = marker.match(block.text());
if (!match.hasMatch())
return;
const QString newText = match.captured(0) + info;
if (newText == block.text())
return;
QTextCursor cursor(block);
cursor.beginEditBlock();
cursor.setPosition(block.position());
cursor.setPosition(block.position() + block.text().length(), QTextCursor::KeepAnchor);
cursor.insertText(newText);
cursor.endEditBlock();
}
QRectF MarkdownEditor::copyButtonRect(int index) const QRectF MarkdownEditor::copyButtonRect(int index) const
{ {
const QRectF rect = codeFenceRect(index); const QRectF rect = codeFenceRect(index);
@@ -606,6 +700,7 @@ void MarkdownEditor::rebuildCodeFences()
static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)")); static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)"));
bool inFence = false; bool inFence = false;
int open = -1; int open = -1;
QString openInfo;
for (QTextBlock block = document()->firstBlock(); block.isValid(); block = block.next()) { for (QTextBlock block = document()->firstBlock(); block.isValid(); block = block.next()) {
if (!fencePattern.match(block.text()).hasMatch()) if (!fencePattern.match(block.text()).hasMatch())
@@ -613,10 +708,13 @@ void MarkdownEditor::rebuildCodeFences()
if (!inFence) { if (!inFence) {
inFence = true; inFence = true;
open = block.blockNumber(); open = block.blockNumber();
openInfo = fenceInfo(block.text());
} else { } else {
CodeFence fence; CodeFence fence;
fence.open = open; fence.open = open;
fence.close = block.blockNumber(); fence.close = block.blockNumber();
fence.info = openInfo;
fence.language = CodeHighlight::fromInfoString(openInfo);
m_codeFences.append(fence); m_codeFences.append(fence);
inFence = false; inFence = false;
open = -1; open = -1;
@@ -625,6 +723,8 @@ void MarkdownEditor::rebuildCodeFences()
if (inFence && open >= 0) { if (inFence && open >= 0) {
CodeFence fence; CodeFence fence;
fence.open = open; fence.open = open;
fence.info = openInfo;
fence.language = CodeHighlight::fromInfoString(openInfo);
m_codeFences.append(fence); m_codeFences.append(fence);
} }
@@ -637,17 +737,25 @@ void MarkdownEditor::mouseMoveEvent(QMouseEvent *event)
if (m_sourceMode) if (m_sourceMode)
return; return;
int hovered = -1; int hoveredCopy = -1;
int hoveredLanguage = -1;
for (int index = 0; index < m_codeFences.size(); ++index) { for (int index = 0; index < m_codeFences.size(); ++index) {
if (languageRect(index).contains(event->pos())) {
hoveredLanguage = index;
break;
}
if (copyButtonRect(index).contains(event->pos())) { if (copyButtonRect(index).contains(event->pos())) {
hovered = index; hoveredCopy = index;
break; break;
} }
} }
if (hovered != m_hoveredFence) { if (hoveredCopy != m_hoveredFence || hoveredLanguage != m_hoveredLanguage) {
m_hoveredFence = hovered; m_hoveredFence = hoveredCopy;
viewport()->setCursor(hovered >= 0 ? Qt::PointingHandCursor : Qt::IBeamCursor); m_hoveredLanguage = hoveredLanguage;
viewport()->setCursor((hoveredCopy >= 0 || hoveredLanguage >= 0)
? Qt::PointingHandCursor
: Qt::IBeamCursor);
viewport()->update(); viewport()->update();
} }
} }
@@ -655,6 +763,12 @@ void MarkdownEditor::mouseMoveEvent(QMouseEvent *event)
void MarkdownEditor::mousePressEvent(QMouseEvent *event) void MarkdownEditor::mousePressEvent(QMouseEvent *event)
{ {
if (!m_sourceMode && event->button() == Qt::LeftButton) { if (!m_sourceMode && event->button() == Qt::LeftButton) {
for (int index = 0; index < m_codeFences.size(); ++index) {
if (languageRect(index).contains(event->pos())) {
showLanguageMenu(index);
return;
}
}
for (int index = 0; index < m_codeFences.size(); ++index) { for (int index = 0; index < m_codeFences.size(); ++index) {
if (!copyButtonRect(index).contains(event->pos())) if (!copyButtonRect(index).contains(event->pos()))
continue; continue;
+9
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "CodeHighlight.h"
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QVector> #include <QVector>
@@ -66,7 +68,11 @@ private:
void indentSelection(bool outdent); void indentSelection(bool outdent);
QRectF codeFenceRect(int index) const; QRectF codeFenceRect(int index) const;
QRectF copyButtonRect(int index) const; QRectF copyButtonRect(int index) const;
QRectF languageRect(int index) const;
QString codeFenceText(int index) const; QString codeFenceText(int index) const;
QString languageLabel(int index) const;
void showLanguageMenu(int index);
void setCodeFenceLanguage(int index, const QString &info);
bool acceptsLocalFiles(const QMimeData *mime) const; bool acceptsLocalFiles(const QMimeData *mime) const;
void selectedBlockRange(int *firstBlock, int *lastBlock) const; void selectedBlockRange(int *firstBlock, int *lastBlock) const;
void selectBlocks(int firstBlock, int lastBlock); void selectBlocks(int firstBlock, int lastBlock);
@@ -76,6 +82,8 @@ private:
struct CodeFence { struct CodeFence {
int open = -1; // 开围栏所在行 int open = -1; // 开围栏所在行
int close = -1; // 闭围栏所在行,未闭合时为 -1 int close = -1; // 闭围栏所在行,未闭合时为 -1
QString info; // 围栏上的语言标记
CodeHighlight::Language language = CodeHighlight::Language::None;
}; };
QWidget *m_lineNumberArea = nullptr; QWidget *m_lineNumberArea = nullptr;
@@ -87,5 +95,6 @@ private:
QVector<CodeFence> m_codeFences; QVector<CodeFence> m_codeFences;
QTimer *m_fenceTimer = nullptr; QTimer *m_fenceTimer = nullptr;
int m_hoveredFence = -1; int m_hoveredFence = -1;
int m_hoveredLanguage = -1;
int m_copiedFence = -1; int m_copiedFence = -1;
}; };
Binary file not shown.
+14 -8
View File
@@ -372,37 +372,43 @@
<context> <context>
<name>MarkdownEditor</name> <name>MarkdownEditor</name>
<message> <message>
<location filename="../src/MarkdownEditor.cpp" line="549" /> <location filename="../src/MarkdownEditor.cpp" line="561" />
<location filename="../src/MarkdownEditor.cpp" line="581" /> <location filename="../src/MarkdownEditor.cpp" line="675" />
<source>已复制</source> <source>已复制</source>
<translation>Copied</translation> <translation>Copied</translation>
</message> </message>
<message> <message>
<location filename="../src/MarkdownEditor.cpp" line="549" /> <location filename="../src/MarkdownEditor.cpp" line="561" />
<location filename="../src/MarkdownEditor.cpp" line="581" /> <location filename="../src/MarkdownEditor.cpp" line="675" />
<source>复制</source> <source>复制</source>
<translation>Copy</translation> <translation>Copy</translation>
</message> </message>
<message>
<location filename="../src/MarkdownEditor.cpp" line="606" />
<location filename="../src/MarkdownEditor.cpp" line="617" />
<source>纯文本</source>
<translation>Plain text</translation>
</message>
</context> </context>
<context> <context>
<name>QObject</name> <name>QObject</name>
<message> <message>
<location filename="../src/ThemeManager.cpp" line="354" /> <location filename="../src/ThemeManager.cpp" line="364" />
<source>浅色</source> <source>浅色</source>
<translation>Light</translation> <translation>Light</translation>
</message> </message>
<message> <message>
<location filename="../src/ThemeManager.cpp" line="355" /> <location filename="../src/ThemeManager.cpp" line="365" />
<source>深色</source> <source>深色</source>
<translation>Dark</translation> <translation>Dark</translation>
</message> </message>
<message> <message>
<location filename="../src/ThemeManager.cpp" line="356" /> <location filename="../src/ThemeManager.cpp" line="366" />
<source>按时段自动</source> <source>按时段自动</source>
<translation>Scheduled</translation> <translation>Scheduled</translation>
</message> </message>
<message> <message>
<location filename="../src/ThemeManager.cpp" line="359" /> <location filename="../src/ThemeManager.cpp" line="369" />
<source>跟随系统</source> <source>跟随系统</source>
<translation>Follow System</translation> <translation>Follow System</translation>
</message> </message>