From d8cffae18c0479e268eee5a218cf20a820eef5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Tue, 14 Apr 2026 18:06:21 +0200 Subject: [PATCH] feat(dialog-sdk): add Python syntax highlighter and runtime language swapping Add Python syntax highlighting for code editor widgets and support runtime language swapping when the plugin changes codeLanguage mid-session. Contents: - PythonSyntaxHighlighter: keywords, builtins, # comments, triple-quoted multiline strings (same pattern as LuaSyntaxHighlighter) - widget_binding: detect when codeLanguage changes, delete the old QSyntaxHighlighter, install the new one matching the requested language --- .../src/python_syntax_highlighter.hpp | 104 ++++++++++++++++++ .../dialog_protocol/src/widget_binding.cpp | 14 ++- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 pj_plugins/dialog_protocol/src/python_syntax_highlighter.hpp diff --git a/pj_plugins/dialog_protocol/src/python_syntax_highlighter.hpp b/pj_plugins/dialog_protocol/src/python_syntax_highlighter.hpp new file mode 100644 index 00000000..f1a1b2ee --- /dev/null +++ b/pj_plugins/dialog_protocol/src/python_syntax_highlighter.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +namespace PJ { + +/// Minimal Python syntax highlighter for QPlainTextEdit code editors. +class PythonSyntaxHighlighter : public QSyntaxHighlighter { + public: + explicit PythonSyntaxHighlighter(QTextDocument* parent) : QSyntaxHighlighter(parent) { + // Keywords + QTextCharFormat keyword_fmt; + keyword_fmt.setForeground(QColor("#0000ff")); + keyword_fmt.setFontWeight(QFont::Bold); + const char* keywords[] = {"and", "as", "assert", "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"}; + for (const char* kw : keywords) { + rules_.append({QRegularExpression("\\b" + QString(kw) + "\\b"), keyword_fmt}); + } + + // Decorators + rules_.append({QRegularExpression("@\\w+"), keyword_fmt}); + + // Numbers + QTextCharFormat number_fmt; + number_fmt.setForeground(QColor("#098658")); + rules_.append({QRegularExpression("\\b[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?\\b"), number_fmt}); + + // Strings (single and double quoted, single-line) + string_fmt_.setForeground(QColor("#a31515")); + rules_.append({QRegularExpression("\"[^\"]*\""), string_fmt_}); + rules_.append({QRegularExpression("'[^']*'"), string_fmt_}); + + // Single-line comments + comment_fmt_.setForeground(QColor("#008000")); + comment_fmt_.setFontItalic(true); + rules_.append({QRegularExpression("#[^\n]*"), comment_fmt_}); + + // Built-in functions + QTextCharFormat builtin_fmt; + builtin_fmt.setForeground(QColor("#795e26")); + const char* builtins[] = {"print", "len", "range", "type", "int", "float", + "str", "list", "dict", "tuple", "set", "enumerate", + "zip", "map", "filter", "sorted", "abs", "min", + "max", "sum", "isinstance", "hasattr", "getattr"}; + for (const char* bi : builtins) { + rules_.append({QRegularExpression("\\b" + QString(bi) + "\\b"), builtin_fmt}); + } + } + + protected: + void highlightBlock(const QString& text) override { + // Apply single-line rules first. + for (const auto& rule : rules_) { + auto it = rule.pattern.globalMatch(text); + while (it.hasNext()) { + auto match = it.next(); + setFormat(static_cast(match.capturedStart()), static_cast(match.capturedLength()), + rule.format); + } + } + + // Multi-line strings: """ ... """ and ''' ... ''' + // State 0 = normal, 1 = inside """, 2 = inside ''' + handleTripleQuote(text, "\"\"\"", 1); + handleTripleQuote(text, "'''", 2); + } + + private: + void handleTripleQuote(const QString& text, const QString& delimiter, int state) { + qsizetype start_index = 0; + if (previousBlockState() != state) { + start_index = text.indexOf(delimiter); + } + while (start_index >= 0) { + qsizetype end_index = text.indexOf(delimiter, start_index + 3); + qsizetype length; + if (end_index == -1) { + setCurrentBlockState(state); + length = text.length() - start_index; + } else { + length = end_index - start_index + 3; + } + setFormat(static_cast(start_index), static_cast(length), string_fmt_); + start_index = text.indexOf(delimiter, start_index + length); + } + } + + struct Rule { + QRegularExpression pattern; + QTextCharFormat format; + }; + QList rules_; + QTextCharFormat string_fmt_; + QTextCharFormat comment_fmt_; +}; + +} // namespace PJ diff --git a/pj_plugins/dialog_protocol/src/widget_binding.cpp b/pj_plugins/dialog_protocol/src/widget_binding.cpp index 9efd835d..49427046 100644 --- a/pj_plugins/dialog_protocol/src/widget_binding.cpp +++ b/pj_plugins/dialog_protocol/src/widget_binding.cpp @@ -24,6 +24,7 @@ #include #include #include "lua_syntax_highlighter.hpp" +#include "python_syntax_highlighter.hpp" #include namespace PJ { @@ -65,12 +66,19 @@ static void apply_to_widget(QWidget* w, std::string_view name, const PJ::WidgetD if (pte->toPlainText() != new_text) { pte->setPlainText(new_text); } - // Install syntax highlighter on first use. + // Install or swap syntax highlighter when the language changes. if (auto lang = view.codeLanguage(name)) { - if (!pte->property("_pj_code_lang").isValid()) { - pte->setProperty("_pj_code_lang", QString::fromStdString(*lang)); + QString current = pte->property("_pj_code_lang").toString(); + QString requested = QString::fromStdString(*lang); + if (current != requested) { + pte->setProperty("_pj_code_lang", requested); + if (auto* old = pte->document()->findChild()) { + delete old; + } if (*lang == "lua") { new PJ::LuaSyntaxHighlighter(pte->document()); + } else if (*lang == "python") { + new PJ::PythonSyntaxHighlighter(pte->document()); } } }