1 | // Copyright (C) 2022 The Qt Company Ltd. |
2 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only |
3 | |
4 | #include "codehelper.h" |
5 | #include "effectmanager.h" |
6 | #include "syntaxhighlighterdata.h" |
7 | #include <QString> |
8 | |
9 | CodeHelper::CodeHelper(QObject *parent) |
10 | : QObject{parent} |
11 | { |
12 | m_effectManager = static_cast<EffectManager *>(parent); |
13 | m_codeCompletionModel = new CodeCompletionModel(this); |
14 | m_codeCompletionTimer.setInterval(500); |
15 | m_codeCompletionTimer.setSingleShot(true); |
16 | connect(sender: &m_codeCompletionTimer, signal: &QTimer::timeout, context: this, slot: &CodeHelper::showCodeCompletion); |
17 | |
18 | // Cache syntax highlight data |
19 | auto args = SyntaxHighlighterData::reservedArgumentNames(); |
20 | for (const auto &arg : args) |
21 | m_reservedArgumentNames << QString::fromUtf8(utf8: arg); |
22 | auto funcs = SyntaxHighlighterData::reservedFunctionNames(); |
23 | for (const auto &func : funcs) |
24 | m_reservedFunctionNames << QString::fromUtf8(utf8: func); |
25 | auto tags = SyntaxHighlighterData::reservedTagNames(); |
26 | for (const auto &tag : tags) |
27 | m_reservedTagNames << QString::fromUtf8(utf8: tag); |
28 | |
29 | std::sort(first: m_reservedArgumentNames.begin(), last: m_reservedArgumentNames.end()); |
30 | std::sort(first: m_reservedFunctionNames.begin(), last: m_reservedFunctionNames.end()); |
31 | std::sort(first: m_reservedTagNames.begin(), last: m_reservedTagNames.end()); |
32 | } |
33 | |
34 | // Process the keyCode and return true if key was handled and |
35 | // TextEdit itself shouldn't append it. |
36 | bool CodeHelper::processKey(QQuickTextEdit *textEdit, int keyCode, int modifiers) |
37 | { |
38 | int position = textEdit->cursorPosition(); |
39 | const QString &code = textEdit->text(); |
40 | if (m_codeCompletionVisible) { |
41 | // When code completition is visible, some keys have special meening |
42 | if (keyCode == Qt::Key_Escape || keyCode == Qt::Key_Left || keyCode == Qt::Key_Right) { |
43 | setCodeCompletionVisible(false); |
44 | return true; |
45 | } else if (keyCode == Qt::Key_Tab || keyCode == Qt::Key_Return) { |
46 | applyCodeCompletion(textEdit); |
47 | return true; |
48 | } else if (keyCode == Qt::Key_Down) { |
49 | m_codeCompletionModel->nextItem(); |
50 | return true; |
51 | } else if (keyCode == Qt::Key_Up) { |
52 | m_codeCompletionModel->previousItem(); |
53 | return true; |
54 | } else { |
55 | updateCodeCompletion(textEdit); |
56 | } |
57 | } |
58 | if (keyCode == Qt::Key_Return) { |
59 | // See if we are inside multiline comments |
60 | bool = false; |
61 | QString prevCode = code.left(n: position); |
62 | int codeStartPos = prevCode.lastIndexOf(s: "/*" ); |
63 | int codeEndPos = prevCode.lastIndexOf(s: "*/" ); |
64 | if (codeStartPos > codeEndPos) |
65 | multilineComment = true; |
66 | |
67 | // Get only the previous line before pressing return |
68 | QString singleLine = code.left(n: position); |
69 | int lineStartPos = singleLine.lastIndexOf(c: '\n'); |
70 | if (lineStartPos > -1) |
71 | singleLine = singleLine.remove(i: 0, len: lineStartPos + 1); |
72 | |
73 | QString indent = autoIndentGLSLNextLine(codeLine: singleLine, multilineComment); |
74 | textEdit->insert(position, text: indent); |
75 | return true; |
76 | } else if (keyCode == Qt::Key_BraceRight) { |
77 | // Get only the current line, without the last '}' |
78 | QString singleLine = code.left(n: position); |
79 | int lineStartPos = singleLine.lastIndexOf(c: '\n'); |
80 | if (lineStartPos > -1) |
81 | singleLine = singleLine.remove(i: 0, len: lineStartPos + 1); |
82 | |
83 | if (singleLine.trimmed().isEmpty()) { |
84 | // Line only had '}' so unindent it (max) one step |
85 | int startPos = std::max(a: int(position - singleLine.size()), b: position - 4); |
86 | textEdit->remove(start: startPos, end: position); |
87 | } |
88 | return false; |
89 | } else if (keyCode == Qt::Key_Tab) { |
90 | // Replace tabs with spaces |
91 | QString indent = QStringLiteral(" " ); |
92 | textEdit->insert(position, text: indent); |
93 | return true; |
94 | } else if ((modifiers & Qt::ControlModifier) && keyCode == Qt::Key_Space) { |
95 | updateCodeCompletion(textEdit, force: true); |
96 | } |
97 | return false; |
98 | } |
99 | |
100 | |
101 | // Formats GLSL code with simple indent rules. |
102 | // Not a full parser so doesn't work with more complex code. |
103 | // For that, consider ClangFormat etc. |
104 | QString CodeHelper::autoIndentGLSLCode(const QString &code) |
105 | { |
106 | QStringList out; |
107 | // Index (indent level) currently |
108 | int index = 0; |
109 | // Index (indent level) for previous line |
110 | int prevIndex = 0; |
111 | // Indent spaces |
112 | QString indent; |
113 | // True when we are inside multiline comments (/* .. */) |
114 | bool = false; |
115 | // Increased when command continues to next line (e.g. "if ()") |
116 | int nextLineIndents = 0; |
117 | // Stores indent before nextLineIndents started |
118 | int indentBeforeSingles = 0; |
119 | |
120 | QStringList codeList = code.split(sep: '\n'); |
121 | for (const auto &cOrig : codeList) { |
122 | QString c = cOrig.trimmed(); |
123 | |
124 | if (c.isEmpty()) { |
125 | // Lines with only spaces are emptied but kept |
126 | out << c; |
127 | continue; |
128 | } |
129 | |
130 | bool isPreprocessor = c.startsWith(c: '#'); |
131 | if (isPreprocessor) { |
132 | // Preprocesser lines have zero intent |
133 | out << c; |
134 | continue; |
135 | } |
136 | |
137 | bool isTag = c.startsWith(c: '@'); |
138 | |
139 | // Separate content after "//" to commenPart |
140 | QString ; |
141 | int = c.indexOf(s: "//" ); |
142 | if (lineCommentIndex > -1) { |
143 | commentPart = c.last(n: c.length() - lineCommentIndex); |
144 | c = c.first(n: lineCommentIndex); |
145 | // Move spaces from c to comment part (to also trim c) |
146 | while (!c.isEmpty() && c.endsWith(c: ' ')) { |
147 | commentPart.prepend(c: ' '); |
148 | c.chop(n: 1); |
149 | } |
150 | } |
151 | |
152 | // Multiline comments state |
153 | int = c.count(s: "/*" ); |
154 | int = c.count(s: "*/" ); |
155 | int commendsChange = startComments - endComments; |
156 | if (commendsChange > 0) |
157 | multilineComment = true; |
158 | else if (commendsChange < 0) |
159 | multilineComment = false; |
160 | |
161 | if (multilineComment || endComments > startComments) { |
162 | // Lines inside /* .. */ are not touched |
163 | out << cOrig; |
164 | continue; |
165 | } |
166 | |
167 | // Check indent for next line |
168 | int indexChange = 0; |
169 | int startBlocks = c.count(c: '{'); |
170 | int endBlocks = c.count(c: '}'); |
171 | indexChange += startBlocks - endBlocks; |
172 | int startBrackets = c.count(c: '('); |
173 | int endBrackets = c.count(c: ')'); |
174 | indexChange += startBrackets - endBrackets; |
175 | index += indexChange; |
176 | index = std::max(a: index, b: 0); |
177 | indent.clear(); |
178 | int currentIndex = indexChange > 0 ? prevIndex : index; |
179 | if (!isTag) { |
180 | if (currentIndex > 0 && startBlocks > 0 && endBlocks > 0) { |
181 | // Note: "} else {", "} else if () {" |
182 | // Indent one step lower |
183 | currentIndex--; |
184 | } else if (endBrackets > startBrackets) { |
185 | // Note: "variable)" |
186 | // Indent one step higher |
187 | currentIndex++; |
188 | } |
189 | if (!c.startsWith(c: '{')) |
190 | currentIndex += nextLineIndents; |
191 | |
192 | if (!c.isEmpty() && startBlocks == 0 && endBlocks == 0 && indexChange == 0 && !c.endsWith(c: ';') && !c.endsWith(c: ',') && !c.endsWith(c: '(') && !c.endsWith(s: "*/" )) { |
193 | // Note: "if ()", "else if ()", "else", "for (..)" |
194 | // Something that should continue to next line |
195 | nextLineIndents++; |
196 | } else if (nextLineIndents > 0) { |
197 | // Return to indent before e.g. "if (thing) \n if (thing2) \n something;" |
198 | nextLineIndents = indentBeforeSingles; |
199 | indentBeforeSingles = nextLineIndents; |
200 | } |
201 | } |
202 | |
203 | // Apply indent |
204 | QString singleIndentStep = QStringLiteral(" " ); |
205 | for (int i = 0; i < currentIndex; i++) |
206 | indent += singleIndentStep; |
207 | c.prepend(s: indent); |
208 | |
209 | out << (c + commentPart); |
210 | prevIndex = index; |
211 | } |
212 | return out.join(sep: '\n'); |
213 | } |
214 | |
215 | // Takes in the previous line (before pressing return key) |
216 | // and returns suitable indent string with spaces. |
217 | QString CodeHelper::autoIndentGLSLNextLine(const QString &codeLine, bool ) |
218 | { |
219 | int spaces = 0; |
220 | |
221 | // Check how many spaces previous line had |
222 | for (int i = 0; i < codeLine.size() ; i++) { |
223 | if (codeLine.at(i) == ' ') |
224 | spaces++; |
225 | else |
226 | break; |
227 | } |
228 | |
229 | QString c = codeLine.trimmed(); |
230 | bool isPreprocessor = c.startsWith(c: '#'); |
231 | bool isTag = c.startsWith(c: '@'); |
232 | |
233 | // Remove content after "//" |
234 | int = c.indexOf(s: "//" ); |
235 | if (lineCommentIndex > -1) { |
236 | c = c.first(n: lineCommentIndex); |
237 | c = c.trimmed(); |
238 | } |
239 | |
240 | if (!c.isEmpty() && !isPreprocessor && !isTag && !multilineComment) { |
241 | // Check indent for next line |
242 | int index = 0; |
243 | int indexChange = 0; |
244 | int startBlocks = c.count(c: '{'); |
245 | int endBlocks = c.count(c: '}'); |
246 | indexChange += startBlocks - endBlocks; |
247 | int startBrackets = c.count(c: '('); |
248 | int endBrackets = c.count(c: ')'); |
249 | indexChange += startBrackets - endBrackets; |
250 | if (indexChange > 0) { |
251 | // Note: "{" "if () {", "vec4 x = vec4(" |
252 | index++; |
253 | } else if (indexChange < 0 && c.trimmed().size() > 1) { |
254 | // Note: "something; }", but NOT "}" as it has be unindented already |
255 | index--; |
256 | } |
257 | |
258 | if (index == 0 && !c.isEmpty() && !c.endsWith(c: ';') && !c.endsWith(s: "*/" ) && !c.endsWith(c: '}')) { |
259 | // Note: "if ()", "else if ()", "else", "for (..)" |
260 | // Something that should continue to next line |
261 | index++; |
262 | } |
263 | spaces += 4 * index; |
264 | } |
265 | |
266 | QString indent; |
267 | indent.fill(c: ' ', size: spaces); |
268 | return '\n' + indent; |
269 | } |
270 | |
271 | // Get current word under the cursor |
272 | // Word is letters, digits and "_". |
273 | // Also if word starts with "//" that is included |
274 | QString CodeHelper::getCurrentWord(QQuickTextEdit *textEdit) |
275 | { |
276 | if (!textEdit) |
277 | return QString(); |
278 | int cursorPosition = textEdit->cursorPosition(); |
279 | int cPos = cursorPosition - 1; |
280 | int maxPos = textEdit->text().size(); |
281 | QString currentWord; |
282 | QChar c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
283 | while (cPos >= 0 && (c.isLetterOrNumber() || c == '_')) { |
284 | currentWord.prepend(c); |
285 | cPos--; |
286 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
287 | } |
288 | // Special case of "@" tags |
289 | if (cPos >= 1) { |
290 | QString s = textEdit->getText(start: cPos, end: cPos + 1); |
291 | if (s == QStringLiteral("@" )) |
292 | currentWord.prepend(QStringLiteral("@" )); |
293 | } |
294 | cPos = cursorPosition; |
295 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
296 | while (cPos <= maxPos && (c.isLetterOrNumber() || c == '_')) { |
297 | currentWord.append(c); |
298 | cPos++; |
299 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
300 | } |
301 | return currentWord; |
302 | } |
303 | |
304 | // Remove the current word under cursor |
305 | void CodeHelper::removeCurrentWord(QQuickTextEdit *textEdit) |
306 | { |
307 | if (!textEdit) |
308 | return; |
309 | int cursorPosition = textEdit->cursorPosition(); |
310 | int cPos = cursorPosition - 1; |
311 | int maxPos = textEdit->text().size(); |
312 | int firstPos = 0; |
313 | int lastPos = 0; |
314 | QChar c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
315 | while (cPos >= 0 && (c.isLetterOrNumber() || c == '_')) { |
316 | cPos--; |
317 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
318 | } |
319 | // Special case of "@" tags |
320 | if (cPos >= 1) { |
321 | QString s = textEdit->getText(start: cPos, end: cPos + 1); |
322 | if (s == QStringLiteral("@" )) |
323 | cPos--; |
324 | } |
325 | firstPos = cPos + 1; |
326 | cPos = cursorPosition; |
327 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
328 | while (cPos <= maxPos && (c.isLetterOrNumber() || c == '_')) { |
329 | cPos++; |
330 | c = textEdit->getText(start: cPos, end: cPos + 1).front(); |
331 | } |
332 | lastPos = cPos; |
333 | textEdit->remove(start: firstPos, end: lastPos); |
334 | } |
335 | |
336 | bool CodeHelper::codeCompletionVisible() const |
337 | { |
338 | return m_codeCompletionVisible; |
339 | } |
340 | |
341 | void CodeHelper::setCodeCompletionVisible(bool visible) |
342 | { |
343 | if (m_codeCompletionVisible == visible) |
344 | return; |
345 | |
346 | m_codeCompletionVisible = visible; |
347 | if (!m_codeCompletionVisible) |
348 | m_codeCompletionModel->setCurrentIndex(0); |
349 | |
350 | Q_EMIT codeCompletionVisibleChanged(); |
351 | } |
352 | |
353 | // Update and show code completion popup |
354 | // When force is true, do this without a delay. |
355 | void CodeHelper::updateCodeCompletion(QQuickTextEdit *textEdit, bool force) |
356 | { |
357 | m_textEdit = textEdit; |
358 | if (force) |
359 | showCodeCompletion(); |
360 | else |
361 | m_codeCompletionTimer.start(); |
362 | } |
363 | |
364 | void CodeHelper::showCodeCompletion() |
365 | { |
366 | if (!m_textEdit) |
367 | return; |
368 | |
369 | QString currentWord = getCurrentWord(textEdit: m_textEdit); |
370 | QString currentWordCleaned = currentWord; |
371 | bool = false; |
372 | |
373 | // Check if word is comment/tag |
374 | if (currentWordCleaned.size() >= 2 && currentWordCleaned.left(n: 2) == "//" ) { |
375 | isComment = true; |
376 | currentWordCleaned = currentWordCleaned.right(n: currentWordCleaned.size() - 2); |
377 | } |
378 | |
379 | m_codeCompletionModel->beginResetModel(); |
380 | m_codeCompletionModel->clearItems(); |
381 | |
382 | if (!isComment) { |
383 | for (const auto &a : m_reservedArgumentNames) { |
384 | if (a.startsWith(s: currentWordCleaned, cs: Qt::CaseInsensitive) && a.size() > currentWordCleaned.size()) |
385 | m_codeCompletionModel->addItem(text: a, type: CodeCompletionModel::TypeArgument); |
386 | } |
387 | |
388 | for (const auto &a : m_reservedFunctionNames) { |
389 | if (a.startsWith(s: currentWordCleaned, cs: Qt::CaseInsensitive) && a.size() > currentWordCleaned.size()) |
390 | m_codeCompletionModel->addItem(text: a, type: CodeCompletionModel::TypeFunction); |
391 | } |
392 | |
393 | for (const auto &a : m_reservedTagNames) { |
394 | if (a.startsWith(s: currentWord, cs: Qt::CaseInsensitive) && a.size() > currentWord.size()) |
395 | m_codeCompletionModel->addItem(text: a, type: CodeCompletionModel::TypeTag); |
396 | } |
397 | } |
398 | |
399 | m_codeCompletionModel->endResetModel(); |
400 | |
401 | if (!m_codeCompletionModel->m_modelList.isEmpty()) { |
402 | m_codeCompletionModel->setCurrentIndex(0); |
403 | setCodeCompletionVisible(true); |
404 | } else { |
405 | setCodeCompletionVisible(false); |
406 | } |
407 | } |
408 | |
409 | CodeCompletionModel *CodeHelper::codeCompletionModel() const |
410 | { |
411 | return m_codeCompletionModel; |
412 | } |
413 | |
414 | void CodeHelper::applyCodeCompletion(QQuickTextEdit *textEdit) |
415 | { |
416 | CodeCompletionModel::ModelData t = m_codeCompletionModel->currentItem(); |
417 | if (!t.name.isEmpty()) { |
418 | // Replace the current word with code completion one |
419 | removeCurrentWord(textEdit); |
420 | textEdit->insert(position: textEdit->cursorPosition(), text: t.name); |
421 | if (t.type == CodeCompletionModel::TypeFunction) { |
422 | // For functions, place cursor between parentheses, e.g. "sin(|)". |
423 | textEdit->setCursorPosition(textEdit->cursorPosition() - 1); |
424 | } |
425 | } |
426 | setCodeCompletionVisible(false); |
427 | } |
428 | |