Branch data Line data Source code
1 : : #include "MarkdownHighlighter.h"
2 : :
3 : : #include "ui/ThemeManager.h"
4 : :
5 : : #include <QFont>
6 : :
7 : : // ═══════════════════════════════════════════════════════
8 : : // MarkdownHighlighter (Sprint 57)
9 : : // ═══════════════════════════════════════════════════════
10 : :
11 : : // 67.B3: all colors come from the ThemeManager @md_* tokens
12 : : // (docs/DESIGN.md §2); formats are rebuilt on theme switch.
13 : 884 : static QColor mdColor(const char *token) {
14 [ + - + - : 884 : return QColor(ThemeManager::instance().color(QLatin1String(token)));
+ - ]
15 : : }
16 : :
17 : 52 : MarkdownHighlighter::MarkdownHighlighter(QTextDocument *parent)
18 [ + - + - : 52 : : QSyntaxHighlighter(parent) {
+ - + - +
- + - + -
+ - + - +
- ]
19 [ + - ]: 52 : setupFormats();
20 [ + - ]: 52 : setupInlineRules();
21 [ + - ]: 52 : connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this,
22 [ + - ]: 52 : [this](ThemeManager::Theme) {
23 : 0 : m_inlineRules.clear();
24 : 0 : setupFormats();
25 : 0 : setupInlineRules();
26 : 0 : rehighlight();
27 : 0 : });
28 : 52 : }
29 : :
30 : 52 : void MarkdownHighlighter::setupFormats() {
31 : : // Headers
32 [ + - + - ]: 52 : m_h1Fmt = QTextCharFormat();
33 : 52 : m_h1Fmt.setFontWeight(QFont::Bold);
34 [ + - + - : 52 : m_h1Fmt.setForeground(mdColor("@md_heading"));
+ - ]
35 : 52 : m_h1Fmt.setFontPointSize(18);
36 : :
37 [ + - + - ]: 52 : m_h2Fmt = QTextCharFormat();
38 : 52 : m_h2Fmt.setFontWeight(QFont::Bold);
39 [ + - + - : 52 : m_h2Fmt.setForeground(mdColor("@md_heading2"));
+ - ]
40 : 52 : m_h2Fmt.setFontPointSize(15);
41 : :
42 [ + - + - ]: 52 : m_h3Fmt = QTextCharFormat();
43 : 52 : m_h3Fmt.setFontWeight(QFont::Bold);
44 [ + - + - : 52 : m_h3Fmt.setForeground(mdColor("@md_heading3"));
+ - ]
45 : 52 : m_h3Fmt.setFontPointSize(13);
46 : :
47 : : // Blockquote
48 [ + - + - ]: 52 : m_blockquoteFmt = QTextCharFormat();
49 [ + - + - : 52 : m_blockquoteFmt.setForeground(mdColor("@md_quote_fg"));
+ - ]
50 : 52 : m_blockquoteFmt.setFontItalic(true);
51 : :
52 : : // List markers
53 [ + - + - ]: 52 : m_listMarkerFmt = QTextCharFormat();
54 [ + - + - : 52 : m_listMarkerFmt.setForeground(mdColor("@md_list_marker"));
+ - ]
55 : 52 : m_listMarkerFmt.setFontWeight(QFont::Bold);
56 : :
57 : : // Checkboxes
58 [ + - + - ]: 52 : m_cbUncheckedFmt = QTextCharFormat();
59 [ + - + - : 52 : m_cbUncheckedFmt.setForeground(mdColor("@md_cb_unchecked"));
+ - ]
60 [ + - + - ]: 52 : m_cbCheckedFmt = QTextCharFormat();
61 [ + - + - : 52 : m_cbCheckedFmt.setForeground(mdColor("@md_cb_checked"));
+ - ]
62 : 52 : m_cbCheckedFmt.setFontWeight(QFont::Bold);
63 : :
64 : : // Horizontal rule
65 [ + - + - ]: 52 : m_hrFmt = QTextCharFormat();
66 [ + - + - : 52 : m_hrFmt.setForeground(mdColor("@md_hr"));
+ - ]
67 : :
68 : : // Code block (multi-line)
69 [ + - + - ]: 52 : m_codeBlockFmt = QTextCharFormat();
70 [ + - + - : 52 : m_codeBlockFmt.setForeground(mdColor("@md_code_fg"));
+ - ]
71 [ + - + + : 104 : m_codeBlockFmt.setFontFamilies({QStringLiteral("monospace")});
- - ]
72 [ + - + - : 52 : m_codeBlockFmt.setBackground(mdColor("@md_code_bg"));
+ - ]
73 : :
74 : : // Code fence start/end
75 [ + - ]: 52 : m_codeFenceRe = QRegularExpression(QStringLiteral("^\\s*```"));
76 [ + - - - : 156 : }
- - ]
77 : :
78 : 52 : void MarkdownHighlighter::setupInlineRules() {
79 : : // Bold: **text**
80 : : {
81 [ + - ]: 52 : InlineRule rule;
82 [ + - ]: 52 : rule.pattern = QRegularExpression(QStringLiteral("\\*\\*(.+?)\\*\\*"));
83 [ + - ]: 52 : rule.format.setFontWeight(QFont::Bold);
84 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_text"));
+ - ]
85 [ + - ]: 52 : m_inlineRules.append(rule);
86 : 52 : }
87 : :
88 : : // Italic: *text*
89 : : {
90 [ + - ]: 52 : InlineRule rule;
91 : 52 : rule.pattern = QRegularExpression(
92 [ + - ]: 104 : QStringLiteral("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)"));
93 [ + - ]: 52 : rule.format.setFontItalic(true);
94 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_text"));
+ - ]
95 [ + - ]: 52 : m_inlineRules.append(rule);
96 : 52 : }
97 : :
98 : : // Inline code: `code`
99 : : {
100 [ + - ]: 52 : InlineRule rule;
101 [ + - ]: 52 : rule.pattern = QRegularExpression(QStringLiteral("`([^`]+)`"));
102 [ + - + + : 104 : rule.format.setFontFamilies({QStringLiteral("monospace")});
- - ]
103 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_inline_code"));
+ - ]
104 [ + - + - : 52 : rule.format.setBackground(mdColor("@md_inline_code_bg"));
+ - ]
105 [ + - ]: 52 : m_inlineRules.append(rule);
106 : 52 : }
107 : :
108 : : // Links: [text](url)
109 : : {
110 [ + - ]: 52 : InlineRule rule;
111 : 52 : rule.pattern = QRegularExpression(
112 [ + - ]: 104 : QStringLiteral("\\[([^\\]]+)\\]\\(([^)]+)\\)"));
113 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_heading"));
+ - ]
114 [ + - ]: 52 : rule.format.setFontUnderline(true);
115 [ + - ]: 52 : m_inlineRules.append(rule);
116 : 52 : }
117 : :
118 : : // Strikethrough: ~~text~~
119 : : {
120 [ + - ]: 52 : InlineRule rule;
121 [ + - ]: 52 : rule.pattern = QRegularExpression(QStringLiteral("~~(.+?)~~"));
122 [ + - ]: 52 : rule.format.setFontStrikeOut(true);
123 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_strike"));
+ - ]
124 [ + - ]: 52 : m_inlineRules.append(rule);
125 : 52 : }
126 : :
127 : : // Table pipes: |
128 : : {
129 [ + - ]: 52 : InlineRule rule;
130 [ + - ]: 52 : rule.pattern = QRegularExpression(QStringLiteral("\\|"));
131 [ + - + - : 52 : rule.format.setForeground(mdColor("@md_strike"));
+ - ]
132 [ + - ]: 52 : rule.format.setFontWeight(QFont::Bold);
133 [ + - ]: 52 : m_inlineRules.append(rule);
134 : 52 : }
135 [ + - - - : 156 : }
- - ]
136 : :
137 : 124 : void MarkdownHighlighter::highlightInlineRules(const QString &text) {
138 [ + - + - : 868 : for (const auto &rule : m_inlineRules) {
+ + ]
139 [ + - ]: 744 : auto it = rule.pattern.globalMatch(text);
140 [ + - + + ]: 757 : while (it.hasNext()) {
141 [ + - ]: 13 : auto match = it.next();
142 [ + - ]: 13 : int start = match.capturedStart(rule.captureGroup);
143 [ + - ]: 13 : int length = match.capturedLength(rule.captureGroup);
144 [ + - ]: 13 : setFormat(start, length, rule.format);
145 : 13 : }
146 : 744 : }
147 : 124 : }
148 : :
149 : 137 : void MarkdownHighlighter::highlightBlock(const QString &text) {
150 : : // ── Multi-line fenced code block state machine ──
151 : : // State 0 = normal, State 1 = inside code block
152 [ + - ]: 137 : int prevState = previousBlockState();
153 : 137 : bool wasInCode = (prevState == 1);
154 : :
155 [ + - + - : 137 : if (m_codeFenceRe.match(text).hasMatch()) {
+ + ]
156 : : // Toggle code block state
157 [ + + ]: 4 : if (wasInCode) {
158 : : // Closing fence
159 [ + - ]: 2 : setFormat(0, text.length(), m_codeBlockFmt);
160 [ + - ]: 2 : setCurrentBlockState(0);
161 : : } else {
162 : : // Opening fence
163 [ + - ]: 2 : setFormat(0, text.length(), m_codeBlockFmt);
164 [ + - ]: 2 : setCurrentBlockState(1);
165 : : }
166 : 26 : return;
167 : : }
168 : :
169 [ + + ]: 133 : if (wasInCode) {
170 : : // Inside code block — format entire line, no inline rules
171 [ + - ]: 2 : setFormat(0, text.length(), m_codeBlockFmt);
172 [ + - ]: 2 : setCurrentBlockState(1);
173 : 2 : return;
174 : : }
175 : :
176 [ + - ]: 131 : setCurrentBlockState(0);
177 : :
178 : : // ── Block-level rules ──
179 : :
180 : : // Headers: # H1, ## H2, ### H3+
181 [ + + + - : 136 : static QRegularExpression h1Re(QStringLiteral("^#{1}\\s+.+$"));
+ - - - ]
182 [ + + + - : 136 : static QRegularExpression h2Re(QStringLiteral("^#{2}\\s+.+$"));
+ - - - ]
183 [ + + + - : 136 : static QRegularExpression h3Re(QStringLiteral("^#{3,6}\\s+.+$"));
+ - - - ]
184 : :
185 [ + - + - : 131 : if (h1Re.match(text).hasMatch()) {
+ + ]
186 [ + - ]: 2 : setFormat(0, text.length(), m_h1Fmt);
187 : 2 : return; // headers don't need inline rules
188 : : }
189 [ + - + - : 129 : if (h2Re.match(text).hasMatch()) {
+ + ]
190 [ + - ]: 2 : setFormat(0, text.length(), m_h2Fmt);
191 : 2 : return;
192 : : }
193 [ + - + - : 127 : if (h3Re.match(text).hasMatch()) {
+ + ]
194 [ + - ]: 1 : setFormat(0, text.length(), m_h3Fmt);
195 : 1 : return;
196 : : }
197 : :
198 : : // Horizontal rule: --- / *** / ___
199 : : static QRegularExpression hrRe(
200 [ + + + - : 131 : QStringLiteral("^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$"));
+ - - - ]
201 [ + - + - : 126 : if (hrRe.match(text).hasMatch()) {
+ + ]
202 [ + - ]: 2 : setFormat(0, text.length(), m_hrFmt);
203 : 2 : return;
204 : : }
205 : :
206 : : // Blockquote: > text
207 [ + + + - : 129 : static QRegularExpression bqRe(QStringLiteral("^>\\s?"));
+ - - - ]
208 [ + - ]: 124 : auto bqMatch = bqRe.match(text);
209 [ + - + + ]: 124 : if (bqMatch.hasMatch()) {
210 [ + - ]: 1 : setFormat(0, text.length(), m_blockquoteFmt);
211 : : // Still apply inline rules to quoted content
212 [ + - ]: 1 : highlightInlineRules(text);
213 : 1 : return;
214 : : }
215 : :
216 : : // Checkbox: - [ ] or - [x]
217 : : static QRegularExpression cbUncheckedRe(
218 [ + + + - : 128 : QStringLiteral("^(\\s*- \\[ \\])(.*)$"));
+ - - - ]
219 : : static QRegularExpression cbCheckedRe(
220 [ + + + - : 128 : QStringLiteral("^(\\s*- \\[[xX]\\])(.*)$"));
+ - - - ]
221 : :
222 [ + - ]: 123 : auto cbChecked = cbCheckedRe.match(text);
223 [ + - + + ]: 123 : if (cbChecked.hasMatch()) {
224 [ + - + - ]: 4 : setFormat(0, cbChecked.capturedLength(1), m_cbCheckedFmt);
225 [ + - ]: 4 : highlightInlineRules(text);
226 : 4 : return;
227 : : }
228 [ + - ]: 119 : auto cbUnchecked = cbUncheckedRe.match(text);
229 [ + - + + ]: 119 : if (cbUnchecked.hasMatch()) {
230 [ + - + - ]: 8 : setFormat(0, cbUnchecked.capturedLength(1), m_cbUncheckedFmt);
231 [ + - ]: 8 : highlightInlineRules(text);
232 : 8 : return;
233 : : }
234 : :
235 : : // Unordered list marker: - / * / +
236 [ + + + - : 116 : static QRegularExpression ulRe(QStringLiteral("^(\\s*[-*+]\\s)"));
+ - - - ]
237 [ + - ]: 111 : auto ulMatch = ulRe.match(text);
238 [ + - + + ]: 111 : if (ulMatch.hasMatch()) {
239 [ + - + - ]: 5 : setFormat(0, ulMatch.capturedLength(1), m_listMarkerFmt);
240 : : }
241 : :
242 : : // Ordered list marker: 1.
243 [ + + + - : 116 : static QRegularExpression olRe(QStringLiteral("^(\\s*\\d+\\.\\s)"));
+ - - - ]
244 [ + - ]: 111 : auto olMatch = olRe.match(text);
245 [ + - + + ]: 111 : if (olMatch.hasMatch()) {
246 [ + - + - ]: 5 : setFormat(0, olMatch.capturedLength(1), m_listMarkerFmt);
247 : : }
248 : :
249 : : // ── Inline rules (bold, italic, code, links, strikethrough, tables) ──
250 [ + - ]: 111 : highlightInlineRules(text);
251 [ + + + + : 144 : }
+ + ]
|