diff --git a/README.md b/README.md index efd121b..836e4a9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.7.0**。 +当前版本 **0.8.0**。 ## 界面 @@ -26,6 +26,9 @@ - 行号栏、当前行高亮 - 围栏代码块显示成带圆角边框的面板,右上角有「复制」按钮 (源代码模式下不显示这些装饰) +- 代码块按语言做语法高亮:C / C++ / Python / JavaScript / TypeScript / + Java / Rust / Go / JSON / Bash / SQL / YAML,关键字、字符串、数字、 + 注释、预处理指令分色;不认识的标记语言就按等宽纯文本显示 - `Tab` / `Shift+Tab` 整行缩进,选中多行时批量缩进 - 自动换行(`Alt+Z`)、`Ctrl+滚轮` 或 `Ctrl++` / `Ctrl+-` 缩放字号 - 撤销 / 重做、剪切 / 复制 / 粘贴,菜单项会跟随可用状态自动灰显 diff --git a/VoiletMark.pro b/VoiletMark.pro index 65ec86f..c2bdc89 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,6 +1,6 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.7.0 +VERSION = 0.8.0 QT += core gui widgets printsupport @@ -23,6 +23,7 @@ SOURCES += \ src/MarkdownEditor.cpp \ src/MarkdownHighlighter.cpp \ src/MarkdownRenderer.cpp \ + src/CodeHighlight.cpp \ src/LanguageManager.cpp \ src/ThemeManager.cpp @@ -31,6 +32,7 @@ HEADERS += \ src/MarkdownEditor.h \ src/MarkdownHighlighter.h \ src/MarkdownRenderer.h \ + src/CodeHighlight.h \ src/LanguageManager.h \ src/ThemeManager.h diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png index 514743f..81fc04f 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 80f330c..c8bafd0 100644 Binary files a/docs/screenshot-light.png and b/docs/screenshot-light.png differ diff --git a/docs/screenshot-source.png b/docs/screenshot-source.png index fde1901..eea957d 100644 Binary files a/docs/screenshot-source.png and b/docs/screenshot-source.png differ diff --git a/src/CodeHighlight.cpp b/src/CodeHighlight.cpp new file mode 100644 index 0000000..08bd8ae --- /dev/null +++ b/src/CodeHighlight.cpp @@ -0,0 +1,334 @@ +#include "CodeHighlight.h" + +#include +#include + +namespace CodeHighlight { +namespace { + +struct LanguageDef +{ + QSet keywords; + QSet types; + QStringList lineComments; + QString blockStart; + QString blockEnd; + QString quotes = QStringLiteral("\"'"); + bool preprocessor = false; + bool caseInsensitive = false; +}; + +QSet words(const char *list) +{ + QSet set; + const QString text = QString::fromLatin1(list); + for (const QString &word : text.split(QLatin1Char(' '), Qt::SkipEmptyParts)) + set.insert(word); + return set; +} + +const QHash &definitions() +{ + static const QHash table = [] { + QHash defs; + + LanguageDef c; + c.keywords = words("auto break case const continue default do else enum extern for goto if " + "inline long register restrict return short signed sizeof static struct " + "switch typedef union unsigned void volatile while _Bool _Static_assert"); + c.types = words("char double float int size_t ssize_t ptrdiff_t FILE int8_t int16_t int32_t " + "int64_t uint8_t uint16_t uint32_t uint64_t bool"); + c.lineComments = QStringList{QStringLiteral("//")}; + c.blockStart = QStringLiteral("/*"); + c.blockEnd = QStringLiteral("*/"); + c.preprocessor = true; + defs.insert(int(Language::C), c); + + LanguageDef cpp = c; + cpp.keywords.unite(words("alignas alignof and asm bool catch class concept constexpr const_cast " + "co_await co_return co_yield decltype delete dynamic_cast explicit export " + "false friend mutable namespace new noexcept not nullptr operator or " + "override private protected public reinterpret_cast static_cast template " + "this throw true try typeid typename using virtual xor final requires")); + cpp.types.unite(words("std string vector map set unique_ptr shared_ptr size_t")); + defs.insert(int(Language::Cpp), cpp); + + LanguageDef py; + py.keywords = words("and as assert async await break class continue def del elif else except False " + "finally for from global if import in is lambda None nonlocal not or pass raise " + "return True try while with yield match case"); + py.types = words("int float str bytes list dict set tuple bool object"); + py.lineComments = QStringList{QStringLiteral("#")}; + py.quotes = QStringLiteral("\"'"); + defs.insert(int(Language::Python), py); + + LanguageDef js; + js.keywords = words("async await break case catch class const continue debugger default delete do " + "else export extends finally for function if import in instanceof let new of " + "return static super switch this throw try typeof var void while with yield " + "null true false undefined"); + js.types = words("String Number Boolean Object Array Promise Map Set JSON Math Date RegExp"); + js.lineComments = QStringList{QStringLiteral("//")}; + js.blockStart = QStringLiteral("/*"); + js.blockEnd = QStringLiteral("*/"); + js.quotes = QStringLiteral("\"'\x60"); + defs.insert(int(Language::JavaScript), js); + + LanguageDef ts = js; + ts.keywords.unite(words("abstract declare enum implements interface keyof namespace never readonly " + "satisfies type unknown infer is asserts override private protected public " + "any symbol bigint")); + ts.types.unite(words("string number boolean any unknown never void")); + defs.insert(int(Language::TypeScript), ts); + + LanguageDef java; + java.keywords = words("abstract assert break case catch class const continue default do else enum " + "extends final finally for goto if implements import instanceof interface " + "native new package private protected public return static strictfp super " + "switch synchronized this throw throws transient try volatile while var " + "true false null"); + java.types = words("byte short int long float double char boolean void String Object List Map Set"); + java.lineComments = QStringList{QStringLiteral("//")}; + java.blockStart = QStringLiteral("/*"); + java.blockEnd = QStringLiteral("*/"); + defs.insert(int(Language::Java), java); + + LanguageDef rust; + rust.keywords = words("as async await break const continue crate dyn else enum extern false fn for " + "if impl in let loop match mod move mut pub ref return self Self static " + "struct super trait true type unsafe use where while yield"); + rust.types = words("i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 bool char str String Vec Option " + "Result Box"); + rust.lineComments = QStringList{QStringLiteral("//")}; + rust.blockStart = QStringLiteral("/*"); + rust.blockEnd = QStringLiteral("*/"); + defs.insert(int(Language::Rust), rust); + + LanguageDef go; + go.keywords = words("break case chan const continue default defer else fallthrough for func go goto " + "if import interface map package range return select struct switch type var " + "true false nil iota"); + go.types = words("bool byte complex64 complex128 error float32 float64 int int8 int16 int32 int64 " + "rune string uint uint8 uint16 uint32 uint64 uintptr any"); + go.lineComments = QStringList{QStringLiteral("//")}; + go.blockStart = QStringLiteral("/*"); + go.blockEnd = QStringLiteral("*/"); + go.quotes = QStringLiteral("\"'\x60"); + defs.insert(int(Language::Go), go); + + LanguageDef json; + json.keywords = words("true false null"); + json.lineComments = QStringList(); + json.quotes = QStringLiteral("\""); + defs.insert(int(Language::Json), json); + + LanguageDef bash; + bash.keywords = words("if then else elif fi for while until do done case esac function in select " + "time return exit local export readonly declare unset echo printf cd source " + "alias set shift trap true false"); + bash.lineComments = QStringList{QStringLiteral("#")}; + bash.quotes = QStringLiteral("\"'\x60"); + defs.insert(int(Language::Bash), bash); + + LanguageDef sql; + sql.keywords = words("select from where insert into values update set delete create table drop alter " + "index view join left right inner outer full on group by order having limit " + "offset union all as and or not null is in like between distinct case when " + "then else end exists primary key foreign references default unique check"); + sql.types = words("int integer bigint smallint varchar char text date time timestamp boolean float " + "double decimal numeric"); + sql.lineComments = QStringList{QStringLiteral("--")}; + sql.blockStart = QStringLiteral("/*"); + sql.blockEnd = QStringLiteral("*/"); + sql.caseInsensitive = true; + defs.insert(int(Language::Sql), sql); + + LanguageDef yaml; + yaml.keywords = words("true false null yes no on off"); + yaml.lineComments = QStringList{QStringLiteral("#")}; + defs.insert(int(Language::Yaml), yaml); + + return defs; + }(); + + return table; +} + +const LanguageDef *definition(Language language) +{ + const auto &defs = definitions(); + const auto it = defs.constFind(int(language)); + return it == defs.constEnd() ? nullptr : &it.value(); +} + +} // namespace + +Language fromInfoString(const QString &info) +{ + QString name = info.trimmed().toLower(); + if (name.isEmpty()) + return Language::None; + name = name.section(QLatin1Char(' '), 0, 0); + + static const QHash aliases = { + {QStringLiteral("c"), Language::C}, + {QStringLiteral("h"), Language::C}, + {QStringLiteral("cpp"), Language::Cpp}, + {QStringLiteral("c++"), Language::Cpp}, + {QStringLiteral("cc"), Language::Cpp}, + {QStringLiteral("cxx"), Language::Cpp}, + {QStringLiteral("hpp"), Language::Cpp}, + {QStringLiteral("python"), Language::Python}, + {QStringLiteral("py"), Language::Python}, + {QStringLiteral("javascript"), Language::JavaScript}, + {QStringLiteral("js"), Language::JavaScript}, + {QStringLiteral("jsx"), Language::JavaScript}, + {QStringLiteral("mjs"), Language::JavaScript}, + {QStringLiteral("typescript"), Language::TypeScript}, + {QStringLiteral("ts"), Language::TypeScript}, + {QStringLiteral("tsx"), Language::TypeScript}, + {QStringLiteral("java"), Language::Java}, + {QStringLiteral("rust"), Language::Rust}, + {QStringLiteral("rs"), Language::Rust}, + {QStringLiteral("go"), Language::Go}, + {QStringLiteral("golang"), Language::Go}, + {QStringLiteral("json"), Language::Json}, + {QStringLiteral("bash"), Language::Bash}, + {QStringLiteral("sh"), Language::Bash}, + {QStringLiteral("shell"), Language::Bash}, + {QStringLiteral("zsh"), Language::Bash}, + {QStringLiteral("sql"), Language::Sql}, + {QStringLiteral("yaml"), Language::Yaml}, + {QStringLiteral("yml"), Language::Yaml}, + }; + + return aliases.value(name, Language::None); +} + +QVector tokenize(const QString &text, Language language, bool inBlockComment, + bool *newBlockComment) +{ + QVector spans; + if (newBlockComment) + *newBlockComment = inBlockComment; + + const LanguageDef *def = definition(language); + if (!def) + return spans; + + const int length = text.size(); + bool comment = inBlockComment; + int index = 0; + + if (def->preprocessor) { + int start = 0; + while (start < length && text.at(start).isSpace()) + ++start; + if (start < length && text.at(start) == QLatin1Char('#')) { + int end = start + 1; + while (end < length + && (text.at(end).isLetterOrNumber() || text.at(end) == QLatin1Char('_'))) + ++end; + spans.append({start, end - start, Token::Preprocessor}); + index = end; + } + } + + while (index < length) { + if (comment) { + const int end = text.indexOf(def->blockEnd, index); + if (end < 0) { + spans.append({index, length - index, Token::Comment}); + index = length; + break; + } + spans.append({index, end + def->blockEnd.size() - index, Token::Comment}); + index = end + def->blockEnd.size(); + comment = false; + continue; + } + + bool lineComment = false; + for (const QString &prefix : def->lineComments) { + if (text.mid(index, prefix.size()) == prefix) { + spans.append({index, length - index, Token::Comment}); + index = length; + lineComment = true; + break; + } + } + if (lineComment) + break; + + if (!def->blockStart.isEmpty() + && text.mid(index, def->blockStart.size()) == def->blockStart) { + const int end = text.indexOf(def->blockEnd, index + def->blockStart.size()); + if (end < 0) { + spans.append({index, length - index, Token::Comment}); + comment = true; + index = length; + } else { + spans.append({index, end + def->blockEnd.size() - index, Token::Comment}); + index = end + def->blockEnd.size(); + } + continue; + } + + const QChar current = text.at(index); + if (def->quotes.contains(current)) { + int end = index + 1; + while (end < length) { + if (text.at(end) == QLatin1Char('\\')) { + end += 2; + continue; + } + if (text.at(end) == current) { + ++end; + break; + } + ++end; + } + end = qMin(end, length); + spans.append({index, end - index, Token::String}); + index = end; + continue; + } + + if (current.isDigit()) { + int end = index + 1; + while (end < length) { + const QChar ch = text.at(end); + if (ch.isLetterOrNumber() || ch == QLatin1Char('.') || ch == QLatin1Char('_')) + ++end; + else + break; + } + spans.append({index, end - index, Token::Number}); + index = end; + continue; + } + + if (current.isLetter() || current == QLatin1Char('_')) { + int end = index + 1; + while (end < length + && (text.at(end).isLetterOrNumber() || text.at(end) == QLatin1Char('_'))) + ++end; + const QString word = text.mid(index, end - index); + const QString probe = def->caseInsensitive ? word.toLower() : word; + if (def->types.contains(probe)) + spans.append({index, end - index, Token::Type}); + else if (def->keywords.contains(probe)) + spans.append({index, end - index, Token::Keyword}); + index = end; + continue; + } + + ++index; + } + + if (newBlockComment) + *newBlockComment = comment; + return spans; +} + +} // namespace CodeHighlight diff --git a/src/CodeHighlight.h b/src/CodeHighlight.h new file mode 100644 index 0000000..c7f0272 --- /dev/null +++ b/src/CodeHighlight.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +/// 围栏代码块里的轻量语法高亮。 +/// +/// 不依赖任何第三方库,按语言给出一组关键字 + 注释 / 字符串规则, +/// 逐行扫描出 token,由 MarkdownHighlighter 上色。 +namespace CodeHighlight { + +enum class Language { + None = 0, + C, + Cpp, + Python, + JavaScript, + TypeScript, + Java, + Rust, + Go, + Json, + Bash, + Sql, + Yaml, + Count +}; + +enum class Token { Keyword, Type, String, Number, Comment, Preprocessor }; + +struct Span { + int start = 0; + int length = 0; + Token token = Token::Keyword; +}; + +/// 从 ```cpp 这类 info string 里识别语言(大小写不敏感)。 +Language fromInfoString(const QString &info); + +/// 扫描一行,返回需要着色的区间;inBlockComment 传入 / 传出跨行注释状态。 +QVector tokenize(const QString &text, Language language, bool inBlockComment, + bool *newBlockComment); + +} // namespace CodeHighlight diff --git a/src/MarkdownHighlighter.cpp b/src/MarkdownHighlighter.cpp index d24f511..ddb2b9c 100644 --- a/src/MarkdownHighlighter.cpp +++ b/src/MarkdownHighlighter.cpp @@ -8,7 +8,22 @@ namespace { constexpr int StateNormal = 0; -constexpr int StateInFence = 1; + +// 围栏状态:1 + 语言编号 * 2 + 是否在块注释里。 +int fenceState(int language, bool inComment) +{ + return 1 + language * 2 + (inComment ? 1 : 0); +} + +int stateLanguage(int state) +{ + return (state - 1) / 2; +} + +bool stateInComment(int state) +{ + return (state - 1) % 2 != 0; +} QStringList monospaceFamilies() { @@ -103,6 +118,22 @@ void MarkdownHighlighter::rebuildFormats() m_fenceFormat.setFontFixedPitch(true); m_fenceFormat.setForeground(c.editorText); + m_fenceMarkerFormat = QTextCharFormat(); + m_fenceMarkerFormat.setFontFamilies(monospaceFamilies()); + m_fenceMarkerFormat.setFontFixedPitch(true); + m_fenceMarkerFormat.setForeground(c.placeholder); + + auto tokenColor = [](const QColor &color) { + QTextCharFormat format; + format.setForeground(color); + return format; + }; + m_codeKeywordFormat = tokenColor(c.codeKeyword); + m_codeStringFormat = tokenColor(c.codeString); + m_codeNumberFormat = tokenColor(c.codeNumber); + m_codeCommentFormat = tokenColor(c.codeComment); + m_codePreprocessorFormat = tokenColor(c.codePreprocessor); + m_linkFormat = QTextCharFormat(); m_linkFormat.setForeground(c.link); m_linkFormat.setFontUnderline(true); @@ -141,6 +172,24 @@ QTextCharFormat MarkdownHighlighter::compose(const QTextCharFormat &specific) co return result; } +const QTextCharFormat &MarkdownHighlighter::tokenFormat(CodeHighlight::Token token) const +{ + switch (token) { + case CodeHighlight::Token::Keyword: + case CodeHighlight::Token::Type: + return m_codeKeywordFormat; + case CodeHighlight::Token::String: + return m_codeStringFormat; + case CodeHighlight::Token::Number: + return m_codeNumberFormat; + case CodeHighlight::Token::Comment: + return m_codeCommentFormat; + case CodeHighlight::Token::Preprocessor: + return m_codePreprocessorFormat; + } + return m_codeKeywordFormat; +} + QTextCharFormat MarkdownHighlighter::hiddenFormat(const QColor &background) const { // 用“和背景同色 + 极小字号”把标记藏起来:文字仍然留在文档里, @@ -187,46 +236,67 @@ void MarkdownHighlighter::applyDelimited(const QRegularExpression &pattern, cons void MarkdownHighlighter::highlightBlock(const QString &text) { + const int previousState = previousBlockState(); setCurrentBlockState(StateNormal); m_blockBase = QTextCharFormat(); - if (text.isEmpty()) + + const bool wasInFence = previousState > 0; + const int fenceLanguageId = wasInFence ? stateLanguage(previousState) : 0; + const auto fenceLanguage = static_cast(fenceLanguageId); + const bool fenceComment = wasInFence && stateInComment(previousState); + + if (text.isEmpty()) { + // 空行也要把围栏状态传下去,否则代码块会被一条空行截断。 + if (wasInFence) + setCurrentBlockState(fenceState(fenceLanguageId, fenceComment)); return; + } const ThemeColors &c = ThemeManager::instance()->colors(); const bool rendered = m_rendered; const bool active = isActiveLine(); static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)")); - const bool inFence = previousBlockState() == StateInFence; const bool fenceLine = fencePattern.match(text).hasMatch(); // ---- 围栏代码块 ----------------------------------------------------- - if (inFence) { - if (!fenceLine) - setCurrentBlockState(StateInFence); - if (rendered) { - if (fenceLine && !active) { - apply(0, text.length(), hiddenFormat(c.editorBase)); - } else { - m_blockBase = m_fenceFormat; - apply(0, text.length(), m_fenceFormat); - } - } else { - m_blockBase = m_codeFormat; - apply(0, text.length(), m_codeFormat); - } - return; - } - if (fenceLine) { - setCurrentBlockState(StateInFence); - if (rendered) { - if (!active) + if (wasInFence) { + if (fenceLine) { + // 闭合围栏行 + if (rendered && !active) apply(0, text.length(), hiddenFormat(c.editorBase)); else - apply(0, text.length(), m_fenceFormat); - } else { - apply(0, text.length(), m_codeFormat); + apply(0, text.length(), m_fenceMarkerFormat); + return; } + + bool inComment = fenceComment; + bool endsInComment = inComment; + m_blockBase = m_fenceFormat; + apply(0, text.length(), m_fenceFormat); + + if (fenceLanguage != CodeHighlight::Language::None) { + const QVector spans = + CodeHighlight::tokenize(text, fenceLanguage, inComment, &endsInComment); + for (const CodeHighlight::Span &span : spans) + apply(span.start, span.length, compose(tokenFormat(span.token))); + } + + setCurrentBlockState(fenceState(fenceLanguageId, endsInComment)); + return; + } + + if (fenceLine) { + // 开围栏行:从 info string 里认语言 + QString info = text.trimmed(); + info.remove(QRegularExpression(QStringLiteral("^(?:```|~~~)"))); + const CodeHighlight::Language language = CodeHighlight::fromInfoString(info); + setCurrentBlockState(fenceState(int(language), false)); + + if (rendered && !active) + apply(0, text.length(), hiddenFormat(c.editorBase)); + else + apply(0, text.length(), m_fenceMarkerFormat); return; } diff --git a/src/MarkdownHighlighter.h b/src/MarkdownHighlighter.h index 4585147..e6d8b24 100644 --- a/src/MarkdownHighlighter.h +++ b/src/MarkdownHighlighter.h @@ -1,5 +1,7 @@ #pragma once +#include "CodeHighlight.h" + #include #include #include @@ -42,6 +44,7 @@ private: const QTextCharFormat &contentFormat, int contentGroup, const QColor &hideAgainst); QTextCharFormat compose(const QTextCharFormat &specific) const; + const QTextCharFormat &tokenFormat(CodeHighlight::Token token) const; QTextCharFormat hiddenFormat(const QColor &background) const; bool isActiveLine() const; @@ -56,6 +59,12 @@ private: QTextCharFormat m_emphasisFormat; QTextCharFormat m_codeFormat; QTextCharFormat m_fenceFormat; + QTextCharFormat m_fenceMarkerFormat; + QTextCharFormat m_codeKeywordFormat; + QTextCharFormat m_codeStringFormat; + QTextCharFormat m_codeNumberFormat; + QTextCharFormat m_codeCommentFormat; + QTextCharFormat m_codePreprocessorFormat; QTextCharFormat m_linkFormat; QTextCharFormat m_urlFormat; QTextCharFormat m_quoteFormat; diff --git a/src/ThemeManager.cpp b/src/ThemeManager.cpp index e752855..df96e0e 100644 --- a/src/ThemeManager.cpp +++ b/src/ThemeManager.cpp @@ -93,6 +93,11 @@ ThemeColors lightColors() c.emphasis = hex("#4b535c"); c.code = hex("#b4295a"); c.codeBackground = hex("#f2f4f7"); + c.codeKeyword = hex("#0550ae"); + c.codeString = hex("#0a7d3a"); + c.codeNumber = hex("#953800"); + c.codeComment = hex("#6e7781"); + c.codePreprocessor = hex("#8250df"); c.link = hex("#0969da"); c.linkUrl = hex("#6b7480"); c.quote = hex("#6b7480"); @@ -138,6 +143,11 @@ ThemeColors darkColors() c.emphasis = hex("#c3c9d1"); c.code = hex("#ff8fbf"); c.codeBackground = hex("#292c33"); + c.codeKeyword = hex("#79b8ff"); + c.codeString = hex("#7ee787"); + c.codeNumber = hex("#ffab70"); + c.codeComment = hex("#8b949e"); + c.codePreprocessor = hex("#d2a8ff"); c.link = hex("#62a8ff"); c.linkUrl = hex("#7f8791"); c.quote = hex("#8b939d"); diff --git a/src/ThemeManager.h b/src/ThemeManager.h index b72a34a..288fe47 100644 --- a/src/ThemeManager.h +++ b/src/ThemeManager.h @@ -44,6 +44,11 @@ struct ThemeColors QColor emphasis; QColor code; QColor codeBackground; + QColor codeKeyword; + QColor codeString; + QColor codeNumber; + QColor codeComment; + QColor codePreprocessor; QColor link; QColor linkUrl; QColor quote;