[education/labplot] src: [scripting] initial implementation of the completion model, providing auto-complete for pylabplot classes, Python built-ins, and user variables.

Alexander Semke <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 0f8663da0b7aa79ffc0559b4ec5a762aa0f38ba3 by Alexander Semke.
Committed on 01/08/2026 at 20:22.
Pushed by asemke into branch 'master'.

[scripting] initial implementation of the completion model,  providing auto-complete for pylabplot classes, Python built-ins, and user variables.

M  +4    -2    src/CMakeLists.txt
M  +47   -0    src/backend/script/python/PythonScriptRuntime.cpp
M  +3    -0    src/backend/script/python/PythonScriptRuntime.h
A  +19   -0    src/backend/script/python/PythonScriptingHelper.h     [License: GPL(v2.0+)]
A  +285  -0    src/frontend/script/ScriptCompletionModel.cpp     [License: GPL(v2.0+)]
A  +64   -0    src/frontend/script/ScriptCompletionModel.h     [License: GPL(v2.0+)]
M  +12   -1    src/frontend/script/ScriptEditor.cpp
M  +3    -0    src/frontend/script/ScriptEditor.h

https://invent.kde.org/education/labplot/-/commit/0f8663da0b7aa79ffc0559b4ec5a762aa0f38ba3

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index e55952b3fd..0e8a1cdd05 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -186,6 +186,7 @@ if(ENABLE_SCRIPTING)
     list(APPEND GUI_SOURCES
         ${FRONTEND_DIR}/dockwidgets/ScriptDock.cpp
         ${FRONTEND_DIR}/script/ScriptEditor.cpp
+        ${FRONTEND_DIR}/script/ScriptCompletionModel.cpp
         ${FRONTEND_DIR}/SettingsEditorPage.cpp
         ${FRONTEND_DIR}/SettingsScriptingPage.cpp)
 endif()
@@ -1005,8 +1006,9 @@ if(APPLE) # Apple app package
     MACOSX_BUNDLE_SHORT_VERSION_STRING "${labplot_VERSION}"
     MACOSX_BUNDLE_GUI_IDENTIFIER "org.kde.labplot")
 
-    # Add entitlements for code signing to allow loading Python packages with different signatures
-    # Required for user-installed scientific packages (numpy, scipy, etc.) on hardened runtime
+    # Add entitlements for code signing to allow loading Python packages with different signatures.
+    # Required to be able to install user-specific packages (numpy, etc.) and not being blocked by the
+    # check for the TeamID created during the signing step.
     set_target_properties(labplot PROPERTIES
         XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/labplot.entitlements"
     )
diff --git a/src/backend/script/python/PythonScriptRuntime.cpp b/src/backend/script/python/PythonScriptRuntime.cpp
index c811808881..0f043ab950 100644
--- a/src/backend/script/python/PythonScriptRuntime.cpp
+++ b/src/backend/script/python/PythonScriptRuntime.cpp
@@ -861,3 +861,50 @@ QString PythonScriptRuntime::pyUnicodeToQString(PyObject* obj) {
 	Py_DECREF(bytes);
 	return QString::fromUtf8(charPtr);
 }
+
+// Global helper function for code completion (avoids Python.h in frontend)
+QStringList getPylabplotSymbolsHelper() {
+	return PythonScriptRuntime::getPylabplotSymbols();
+}
+
+QStringList PythonScriptRuntime::getPylabplotSymbols() {
+	QStringList symbols;
+
+	// Check if Python is initialized
+	if (!Py_IsInitialized())
+		return symbols;
+
+	PyGILState_STATE gil = PyGILState_Ensure();
+
+	// Import pylabplot module
+	PyObject* module = PyImport_ImportModule("pylabplot");
+	if (!module) {
+		PyErr_Clear();
+		PyGILState_Release(gil);
+		return symbols;
+	}
+
+	// Get all symbols from module using dir()
+	PyObject* dirList = PyObject_Dir(module);
+	if (dirList && PyList_Check(dirList)) {
+		Py_ssize_t size = PyList_Size(dirList);
+		for (Py_ssize_t i = 0; i < size; ++i) {
+			PyObject* item = PyList_GetItem(dirList, i); // Borrowed reference
+			if (PyUnicode_Check(item)) {
+				QString name = PythonScriptRuntime::pyUnicodeToQString(item);
+				if (!name.isEmpty()) {
+					// Skip private symbols
+					if (!name.startsWith(QLatin1Char('_'))) {
+						symbols.append(name);
+					}
+				}
+			}
+		}
+		Py_DECREF(dirList);
+	}
+
+	Py_DECREF(module);
+	PyGILState_Release(gil);
+
+	return symbols;
+}
diff --git a/src/backend/script/python/PythonScriptRuntime.h b/src/backend/script/python/PythonScriptRuntime.h
index 7b2596a2f8..8217a107e8 100644
--- a/src/backend/script/python/PythonScriptRuntime.h
+++ b/src/backend/script/python/PythonScriptRuntime.h
@@ -27,6 +27,9 @@ public:
 	virtual bool cancel() override;
 	virtual bool exec(const QString&) override;
 
+	// Static helper for code completion
+	static QStringList getPylabplotSymbols();
+
 private:
 	PythonLogger* m_loggerStdOut{nullptr}; // PythonLogger instance to replace sys.stdout in the python interpreter
 	PythonLogger* m_loggerStdErr{nullptr}; // PythonLogger instance to replace sys.stderr in the python interpreter
diff --git a/src/backend/script/python/PythonScriptingHelper.h b/src/backend/script/python/PythonScriptingHelper.h
new file mode 100644
index 0000000000..7c91b6073c
--- /dev/null
+++ b/src/backend/script/python/PythonScriptingHelper.h
@@ -0,0 +1,19 @@
+/*
+	File                 : PythonScriptingHelper.h
+	Project              : LabPlot
+	Description          : Helper functions for Python scripting (frontend-safe)
+	--------------------------------------------------------------------
+	SPDX-FileCopyrightText: 2026 Alexander Semke <[email protected]>
+	SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#ifndef PYTHONSCRIPTINGHELPER_H
+#define PYTHONSCRIPTINGHELPER_H
+
+#include <QStringList>
+
+// Helper function to get pylabplot symbols without requiring Python.h
+// This allows frontend code to query symbols without Python header dependencies
+QStringList getPylabplotSymbolsHelper();
+
+#endif // PYTHONSCRIPTINGHELPER_H
diff --git a/src/frontend/script/ScriptCompletionModel.cpp b/src/frontend/script/ScriptCompletionModel.cpp
new file mode 100644
index 0000000000..b9eeae79ee
--- /dev/null
+++ b/src/frontend/script/ScriptCompletionModel.cpp
@@ -0,0 +1,285 @@
+/*
+	File                 : ScriptCompletionModel.cpp
+	Project              : LabPlot
+	Description          : Code completion model for Python script editor
+	--------------------------------------------------------------------
+	SPDX-FileCopyrightText: 2026 Alexander Semke <[email protected]>
+	SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "ScriptCompletionModel.h"
+#include "ScriptEditor.h"
+#include "backend/script/Script.h"
+#include "backend/script/python/PythonScriptingHelper.h"
+#include <backend/lib/macros.h>
+
+#include <KTextEditor/View>
+#include <KTextEditor/Document>
+
+#include <QTimer>
+#include <QSet>
+#include <QRegularExpression>
+#include <algorithm>
+
+ScriptCompletionModel::ScriptCompletionModel(ScriptEditor* parent)
+	: KTextEditor::CodeCompletionModel(parent)
+	, m_editor(parent) {
+	m_debounceTimer = new QTimer(this);
+	m_debounceTimer->setSingleShot(true);
+	m_debounceTimer->setInterval(200);
+	connect(m_debounceTimer, &QTimer::timeout, this, &ScriptCompletionModel::startCompletionRequest);
+
+	initPythonBuiltins(); // initialize Python built-ins
+	initPylabplotSymbols(); // initialize pylabplot symbols
+}
+
+ScriptCompletionModel::~ScriptCompletionModel() {
+	m_debounceTimer->stop();
+}
+
+void ScriptCompletionModel::initPythonBuiltins() {
+	// Common Python built-in functions
+	m_pythonBuiltins = {
+		QStringLiteral("print"),
+		QStringLiteral("len"),
+		QStringLiteral("range"),
+		QStringLiteral("str"),
+		QStringLiteral("int"),
+		QStringLiteral("float"),
+		QStringLiteral("list"),
+		QStringLiteral("dict"),
+		QStringLiteral("set"),
+		QStringLiteral("tuple"),
+		QStringLiteral("bool"),
+		QStringLiteral("sum"),
+		QStringLiteral("min"),
+		QStringLiteral("max"),
+		QStringLiteral("abs"),
+		QStringLiteral("round"),
+		QStringLiteral("sorted"),
+		QStringLiteral("enumerate"),
+		QStringLiteral("zip"),
+		QStringLiteral("map"),
+		QStringLiteral("filter"),
+		QStringLiteral("open"),
+		QStringLiteral("type"),
+		QStringLiteral("isinstance"),
+		QStringLiteral("hasattr"),
+		QStringLiteral("getattr"),
+		QStringLiteral("setattr"),
+	};
+}
+
+bool ScriptCompletionModel::initPylabplotSymbols() {
+	DEBUG(Q_FUNC_INFO)
+
+	// Get pylabplot symbols via global helper (avoids Python.h dependency)
+	QStringList symbolNames = getPylabplotSymbolsHelper();
+
+	if (symbolNames.isEmpty()) {
+		WARN("No pylabplot symbols extracted - Python may not be initialized")
+		return false;
+	}
+
+	DEBUG(Q_FUNC_INFO << ", Found " << symbolNames.size() << " symbols in pylabplot")
+
+	// Create completion items for each symbol
+	for (const QString& name : symbolNames) {
+		CompletionItem item;
+		item.name = name;
+
+		// Heuristic type detection based on naming conventions
+		// Classes typically start with uppercase
+		if (!name.isEmpty() && name[0].isUpper()) {
+			item.isClass = true;
+		}
+		// Function names often contain common patterns
+		else if (name.contains(QLatin1String("get")) || name.contains(QLatin1String("set")) || name == QLatin1String("project")) {
+			item.isFunction = true;
+		}
+
+		m_pylabplotSymbols.append(item);
+	}
+
+	DEBUG(Q_FUNC_INFO << ", Successfully extracted " << m_pylabplotSymbols.size() << " pylabplot symbols")
+	return true;
+}
+
+void ScriptCompletionModel::updateUserVariables(const QString& scriptText) {
+	// Extract variable names from script using simple regex
+	// Matches: variable_name = ...
+	static QRegularExpression assignPattern(QStringLiteral(R"(^(\w+)\s*=)"), QRegularExpression::MultilineOption);
+
+	QSet<QString> variables;
+	QRegularExpressionMatchIterator it = assignPattern.globalMatch(scriptText);
+
+	while (it.hasNext()) {
+		QRegularExpressionMatch match = it.next();
+		QString varName = match.captured(1);
+
+		// Skip if it starts with underscore or is a Python keyword
+		if (!varName.isEmpty() && !varName.startsWith(QLatin1Char('_'))) {
+			// Basic keyword check
+			if (varName != QLatin1String("if") && varName != QLatin1String("for") && varName != QLatin1String("while") && varName != QLatin1String("def")
+				&& varName != QLatin1String("class") && varName != QLatin1String("import") && varName != QLatin1String("from")
+				&& varName != QLatin1String("return") && varName != QLatin1String("yield") && varName != QLatin1String("with")
+				&& varName != QLatin1String("as")) {
+				variables.insert(varName);
+			}
+		}
+	}
+
+	m_userVariables = variables.values();
+	DEBUG(Q_FUNC_INFO << ", Found " << m_userVariables.size() << " user variables")
+}
+
+void ScriptCompletionModel::completionInvoked(KTextEditor::View* view, const KTextEditor::Range& range, InvocationType invocationType) {
+	Q_UNUSED(invocationType)
+
+	m_pendingView = view;
+	m_pendingRange = range;
+	m_debounceTimer->start();
+}
+
+void ScriptCompletionModel::startCompletionRequest() {
+	if (!m_pendingView)
+		return;
+
+	auto currentPos = m_pendingView->cursorPosition();
+
+	// Get current word being typed
+	auto currentWordRange = m_pendingView->document()->wordRangeAt(currentPos);
+	QString prefix = m_pendingView->document()->text(currentWordRange);
+
+	// Update user variables from current script
+	QString scriptText = m_pendingView->document()->text();
+	updateUserVariables(scriptText);
+
+	// Collect all completion candidates
+	QSet<QString> allSymbolsSet;
+
+	// Add pylabplot symbols
+	for (const auto& item : m_pylabplotSymbols)
+		allSymbolsSet.insert(item.name);
+
+	// Add Python built-ins
+	for (const auto& builtin : m_pythonBuiltins)
+		allSymbolsSet.insert(builtin);
+
+	// Add user variables
+	for (const auto& var : m_userVariables)
+		allSymbolsSet.insert(var);
+
+	// Filter by prefix
+	beginResetModel();
+	m_matches.clear();
+
+	for (const QString& symbol : allSymbolsSet) {
+		if (prefix.isEmpty() || symbol.startsWith(prefix, Qt::CaseInsensitive)) {
+			CompletionItem item;
+			item.name = symbol;
+
+			// Determine type
+			for (const auto& pylabplotItem : m_pylabplotSymbols) {
+				if (pylabplotItem.name == symbol) {
+					item.isClass = pylabplotItem.isClass;
+					item.isFunction = pylabplotItem.isFunction;
+					item.isEnum = pylabplotItem.isEnum;
+					break;
+				}
+			}
+
+			// Check if it's a Python built-in
+			if (m_pythonBuiltins.contains(symbol))
+				item.isFunction = true;
+
+			// Check if it's a user variable
+			if (m_userVariables.contains(symbol))
+				item.isVariable = true;
+
+			m_matches.append(item);
+		}
+	}
+
+	// Sort alphabetically
+	std::sort(m_matches.begin(), m_matches.end(), [](const auto& a, const auto& b) {
+		return a.name.localeAwareCompare(b.name) < 0;
+	});
+
+	setRowCount(m_matches.size());
+	endResetModel();
+
+	// DEBUG(Q_FUNC_INFO << ", Showing " << m_matches.size() << " completions for prefix '" << prefix << "'")
+}
+
+QVariant ScriptCompletionModel::data(const QModelIndex& index, int role) const {
+	if (!index.isValid() || index.row() >= m_matches.count())
+		return QVariant();
+
+	const CompletionItem& item = m_matches.at(index.row());
+
+	switch (role) {
+	case Qt::DisplayRole:
+		if (index.column() == Name)
+			return item.name;
+		break;
+
+	case Qt::DecorationRole:
+		if (index.column() == Icon) {
+			// Return appropriate icon based on type
+			if (item.isClass)
+				return QIcon::fromTheme(QStringLiteral("code-class"));
+			else if (item.isFunction)
+				return QIcon::fromTheme(QStringLiteral("code-function"));
+			else if (item.isEnum)
+				return QIcon::fromTheme(QStringLiteral("flag"));
+			else if (item.isVariable)
+				return QIcon::fromTheme(QStringLiteral("code-variable"));
+		}
+		break;
+	}
+
+	return QVariant();
+}
+
+void ScriptCompletionModel::executeCompletionItem(KTextEditor::View* view, const KTextEditor::Range& word, const QModelIndex& index) const {
+	if (!index.isValid() || !view)
+		return;
+
+	const CompletionItem& item = m_matches.at(index.row());
+	QString textToInsert = item.name;
+
+	// Add parentheses for functions and classes
+	if (item.isFunction || item.isClass)
+		textToInsert += QStringLiteral("()");
+
+	// Replace the current word with the completion
+	KTextEditor::Range rangeToReplace = view->document()->wordRangeAt(view->cursorPosition());
+	view->document()->replaceText(rangeToReplace, textToInsert);
+
+	// Position cursor inside parentheses for functions/classes
+	if (item.isFunction || item.isClass) {
+		KTextEditor::Cursor newCursorPos = rangeToReplace.start();
+		newCursorPos.setColumn(newCursorPos.column() + item.name.length() + 1);
+		view->setCursorPosition(newCursorPos);
+	}
+}
+
+KTextEditor::Range ScriptCompletionModel::completionRange(KTextEditor::View* view, const KTextEditor::Cursor& cursor) {
+	return view->document()->wordRangeAt(cursor);
+}
+
+bool ScriptCompletionModel::shouldStartCompletion(KTextEditor::View* view, const QString& insertedText, bool userInsertion, const KTextEditor::Cursor& position) {
+	Q_UNUSED(view)
+	Q_UNUSED(userInsertion)
+	Q_UNUSED(position)
+
+	if (!insertedText.isEmpty()) {
+		const QChar lastChar = insertedText.back();
+		// Start completion after typing a letter or underscore
+		if (lastChar.isLetter() || lastChar == QLatin1Char('_'))
+			return true;
+	}
+
+	return false;
+}
diff --git a/src/frontend/script/ScriptCompletionModel.h b/src/frontend/script/ScriptCompletionModel.h
new file mode 100644
index 0000000000..33c2b6928b
--- /dev/null
+++ b/src/frontend/script/ScriptCompletionModel.h
@@ -0,0 +1,64 @@
+/*
+	File                 : ScriptCompletionModel.h
+	Project              : LabPlot
+	Description          : Code completion model for Python script editor
+	--------------------------------------------------------------------
+	SPDX-FileCopyrightText: 2026 Alexander Semke <[email protected]>
+	SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#ifndef SCRIPTCOMPLETIONMODEL_H
+#define SCRIPTCOMPLETIONMODEL_H
+
+#include <KTextEditor/CodeCompletionModel>
+#include <KTextEditor/CodeCompletionModelControllerInterface>
+#include <KTextEditor/Range>
+
+class ScriptEditor;
+class QTimer;
+
+class ScriptCompletionModel : public KTextEditor::CodeCompletionModel,
+							   public KTextEditor::CodeCompletionModelControllerInterface {
+	Q_OBJECT
+	Q_INTERFACES(KTextEditor::CodeCompletionModelControllerInterface)
+
+public:
+	struct CompletionItem {
+		QString name;
+		bool isFunction = false;
+		bool isClass = false;
+		bool isEnum = false;
+		bool isVariable = false;
+	};
+
+	explicit ScriptCompletionModel(ScriptEditor* parent);
+	~ScriptCompletionModel() override;
+
+	void completionInvoked(KTextEditor::View*, const KTextEditor::Range&, InvocationType) override;
+	void executeCompletionItem(KTextEditor::View*, const KTextEditor::Range&, const QModelIndex&) const override;
+	QVariant data(const QModelIndex&, int) const override;
+
+	KTextEditor::Range completionRange(KTextEditor::View*, const KTextEditor::Cursor&) override;
+	bool shouldStartCompletion(KTextEditor::View*, const QString&, bool, const KTextEditor::Cursor&) override;
+
+	bool initPylabplotSymbols();
+
+private Q_SLOTS:
+	void startCompletionRequest();
+
+private:
+	ScriptEditor* m_editor{nullptr};
+	QList<CompletionItem> m_matches;
+	QList<CompletionItem> m_pylabplotSymbols;
+	QStringList m_userVariables;
+	QStringList m_pythonBuiltins;
+
+	QTimer* m_debounceTimer{nullptr};
+	KTextEditor::View* m_pendingView{nullptr};
+	KTextEditor::Range m_pendingRange;
+
+	void updateUserVariables(const QString& scriptText);
+	void initPythonBuiltins();
+};
+
+#endif // SCRIPTCOMPLETIONMODEL_H
diff --git a/src/frontend/script/ScriptEditor.cpp b/src/frontend/script/ScriptEditor.cpp
index 1ccddd8c6c..98d8faed55 100644
--- a/src/frontend/script/ScriptEditor.cpp
+++ b/src/frontend/script/ScriptEditor.cpp
@@ -4,10 +4,12 @@
 	Description          : Script editor
 	--------------------------------------------------------------------
 	SPDX-FileCopyrightText: 2025 Israel Galadima <[email protected]>
+	SPDX-FileCopyrightText: 2026 Alexander Semke <[email protected]>
 	SPDX-License-Identifier: GPL-2.0-or-later
 */
 
 #include "ScriptEditor.h"
+#include "ScriptCompletionModel.h"
 #include "backend/script/Script.h"
 #include <backend/lib/macros.h>
 
@@ -67,9 +69,18 @@ ScriptEditor::ScriptEditor(Script* script, QWidget* parent)
 	// Setup context menu for output
 	ui.output->setContextMenuPolicy(Qt::CustomContextMenu);
 	connect(ui.output, &QTextBrowser::customContextMenuRequested, this, &ScriptEditor::showOutputContextMenu);
+
+	// Create and register code completion model in KTextEditor view
+	m_completionModel = new ScriptCompletionModel(this);
+	m_kTextEditorView->registerCompletionModel(m_completionModel);
+	m_kTextEditorView->setAutomaticInvocationEnabled(true);
 }
 
-ScriptEditor::~ScriptEditor() {    
+ScriptEditor::~ScriptEditor() {
+	// Unregister completion model
+	if (m_completionModel && m_kTextEditorView)
+		m_kTextEditorView->unregisterCompletionModel(m_completionModel);
+
 	KConfig config;
 	auto group = config.group(QStringLiteral("ScriptEditor"));
 	// we dont manage default editor font or themes ourselves, so no need to save in our config
diff --git a/src/frontend/script/ScriptEditor.h b/src/frontend/script/ScriptEditor.h
index f3cf938cf0..f2423e67ef 100644
--- a/src/frontend/script/ScriptEditor.h
+++ b/src/frontend/script/ScriptEditor.h
@@ -4,6 +4,7 @@
 	Description          : Script editor
 	--------------------------------------------------------------------
 	SPDX-FileCopyrightText: 2025 Israel Galadima <[email protected]>
+	SPDX-FileCopyrightText: 2026 Alexander Semke <[email protected]>
 	SPDX-License-Identifier: GPL-2.0-or-later
 */
 
@@ -18,6 +19,7 @@ class QMenu;
 class Script;
 class QToolBar;
 class QToolButton;
+class ScriptCompletionModel;
 namespace KTextEditor{
 class View;
 }
@@ -54,6 +56,7 @@ private:
 	QAction* m_clearOutputAction{nullptr};
 	QAction* m_copySelectedAction{nullptr};
 	QAction* m_copyAllOutputAction{nullptr};
+	ScriptCompletionModel* m_completionModel{nullptr};
 
 	void initActions();
 	void initMenus();
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.