[education/labplot] src/frontend: [scripting] use QTextBrowser instead of QPlainTextEdit for the terminal output and highlight errors and warnings.

Alexander Semke <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit c221f5520399f5afd5e4e061e17b56e4c91f225d by Alexander Semke.
Committed on 28/07/2026 at 14:43.
Pushed by asemke into branch 'master'.

[scripting] use QTextBrowser instead of QPlainTextEdit for the terminal output and highlight errors and warnings.

M  +120  -5    src/frontend/script/ScriptEditor.cpp
M  +9    -0    src/frontend/script/ScriptEditor.h
M  +4    -1    src/frontend/ui/script/scripteditorwidget.ui

https://invent.kde.org/education/labplot/-/commit/c221f5520399f5afd5e4e061e17b56e4c91f225d

diff --git a/src/frontend/script/ScriptEditor.cpp b/src/frontend/script/ScriptEditor.cpp
index 655aeb0133..f96d8f7e55 100644
--- a/src/frontend/script/ScriptEditor.cpp
+++ b/src/frontend/script/ScriptEditor.cpp
@@ -27,7 +27,12 @@
 #include <QVariant>
 #include <QFont>
 #include <QObject>
-#include <QPlainTextEdit>
+#include <QTextBrowser>
+#include <QRegularExpression>
+#include <QTextCursor>
+#include <QTextCharFormat>
+#include <QClipboard>
+#include <QApplication>
 
 ScriptEditor::ScriptEditor(Script* script, QWidget* parent)
 	: QWidget(parent), m_script(script) {
@@ -54,6 +59,14 @@ ScriptEditor::ScriptEditor(Script* script, QWidget* parent)
 		});
 
 	ui.output->setReadOnly(true);
+	ui.output->setOpenLinks(false);
+
+	// Connect anchor click handler for line navigation
+	connect(ui.output, &QTextBrowser::anchorClicked, this, &ScriptEditor::handleAnchorClicked);
+
+	// Setup context menu for output
+	ui.output->setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(ui.output, &QTextBrowser::customContextMenuRequested, this, &ScriptEditor::showOutputContextMenu);
 }
 
 ScriptEditor::~ScriptEditor() {    
@@ -104,19 +117,33 @@ void ScriptEditor::initActions() {
 	m_clearOutputAction = new QAction(QIcon::fromTheme(QStringLiteral("edit-clear")), QStringLiteral("Clear Output"), this);
 	m_clearOutputAction->setWhatsThis(QStringLiteral("Clear the output of the script editor"));
 	connect(m_clearOutputAction, &QAction::triggered, this, &ScriptEditor::clearOutput);
+
+	m_copySelectedAction = new QAction(QIcon::fromTheme(QStringLiteral("edit-copy")), QStringLiteral("Copy Selected"), this);
+	m_copySelectedAction->setWhatsThis(QStringLiteral("Copy selected text from output"));
+	connect(m_copySelectedAction, &QAction::triggered, [this]() {
+		QApplication::clipboard()->setText(ui.output->textCursor().selectedText());
+	});
+
+	m_copyAllOutputAction = new QAction(QIcon::fromTheme(QStringLiteral("edit-copy")), QStringLiteral("Copy All Output"), this);
+	m_copyAllOutputAction->setWhatsThis(QStringLiteral("Copy all output text"));
+	connect(m_copyAllOutputAction, &QAction::triggered, [this]() {
+		QApplication::clipboard()->setText(ui.output->toPlainText());
+	});
 }
 
-void ScriptEditor::writeOutput(bool /*isErr*/, const QString& msg) {
+void ScriptEditor::writeOutput(bool isErr, const QString& msg) {
 	DEBUG(Q_FUNC_INFO << ", text = '" << msg.toStdString() << "'")
 	if (msg.isEmpty())
 		return;
 
-	// avoid additional newlines by using a cursor
+	// Process the output text to add links and formatting
+	QString processedHtml = processOutputText(isErr, msg);
+
+	// Insert formatted HTML at the end
 	auto cursor = ui.output->textCursor();
 	cursor.movePosition(QTextCursor::End);
-	cursor.insertText(msg);
 	ui.output->setTextCursor(cursor);
-
+	ui.output->insertHtml(processedHtml);
 }
 
 void ScriptEditor::setSplitterState(const QByteArray& state) {
@@ -159,3 +186,91 @@ void ScriptEditor::clearOutput() {
 	ui.output->setReadOnly(true);
 	setOutputFont(currentOutputFont);
 }
+
+QString ScriptEditor::processOutputText(bool isErr, const QString& text) {
+	QString html;
+	QString escapedText = text.toHtmlEscaped();
+
+	// Detect and format Python traceback patterns
+	// Pattern 1: "File "<string>", line 5, in <module>"
+	static QRegularExpression fileLinePattern(
+		QStringLiteral(R"(File\s+"[^"]*",\s+line\s+(\d+))"),
+		QRegularExpression::CaseInsensitiveOption
+	);
+
+	// Pattern 2: Error/Exception names (e.g., "NameError:", "ValueError:", "TypeError:")
+	static QRegularExpression errorPattern(
+		QStringLiteral(R"(^(\w+Error|\w+Exception|Traceback):)"),
+		QRegularExpression::MultilineOption
+	);
+
+	// Pattern 3: Warning patterns
+	static QRegularExpression warningPattern(
+		QStringLiteral(R"(\bwarning\b|\bWARN\b)"),
+		QRegularExpression::CaseInsensitiveOption
+	);
+
+	QString processedText = escapedText;
+
+	// Add clickable links for line references
+	QRegularExpressionMatchIterator it = fileLinePattern.globalMatch(processedText);
+	int offset = 0;
+	while (it.hasNext()) {
+		QRegularExpressionMatch match = it.next();
+		QString lineNum = match.captured(1);
+		QString matchedText = match.captured(0);
+		QString link = QStringLiteral("<a href=\"line:%1\">%2</a>").arg(lineNum, matchedText);
+
+		int startPos = match.capturedStart(0) + offset;
+		int length = match.capturedLength(0);
+		processedText.replace(startPos, length, link);
+		offset += link.length() - length;
+	}
+
+	// Apply color formatting
+	if (isErr) {
+		// Error output - make it red
+		html = QStringLiteral("<span style=\"color: #d32f2f;\">%1</span>").arg(processedText);
+	} else if (warningPattern.match(processedText).hasMatch()) {
+		// Warning - make it orange
+		html = QStringLiteral("<span style=\"color: #f57c00;\">%1</span>").arg(processedText);
+	} else if (errorPattern.match(processedText).hasMatch()) {
+		// Exception names - make them red
+		html = QStringLiteral("<span style=\"color: #d32f2f;\">%1</span>").arg(processedText);
+	} else {
+		// Normal output
+		html = processedText;
+	}
+
+	return html;
+}
+
+void ScriptEditor::handleAnchorClicked(const QUrl& url) {
+	DEBUG(Q_FUNC_INFO << ", URL = " << url.toString().toStdString())
+
+	// Handle line:N links
+	if (url.scheme() == QLatin1String("line")) {
+		QString lineStr = url.path();
+		bool ok;
+		int line = lineStr.toInt(&ok);
+		if (ok && line > 0 && m_kTextEditorView) {
+			// Jump to the specified line in the editor (1-based)
+			m_kTextEditorView->setCursorPosition(KTextEditor::Cursor(line - 1, 0));
+			m_kTextEditorView->setFocus();
+		}
+	}
+}
+
+void ScriptEditor::showOutputContextMenu(const QPoint& pos) {
+	QMenu menu(this);
+
+	bool hasSelection = ui.output->textCursor().hasSelection();
+	m_copySelectedAction->setEnabled(hasSelection);
+
+	menu.addAction(m_copySelectedAction);
+	menu.addAction(m_copyAllOutputAction);
+	menu.addSeparator();
+	menu.addAction(m_clearOutputAction);
+
+	menu.exec(ui.output->mapToGlobal(pos));
+}
diff --git a/src/frontend/script/ScriptEditor.h b/src/frontend/script/ScriptEditor.h
index dadbee0f47..f3cf938cf0 100644
--- a/src/frontend/script/ScriptEditor.h
+++ b/src/frontend/script/ScriptEditor.h
@@ -37,6 +37,10 @@ public:
 	void registerShortcuts();
 	void unregisterShortcuts();
 
+private Q_SLOTS:
+	void handleAnchorClicked(const QUrl&);
+	void showOutputContextMenu(const QPoint&);
+
 public Q_SLOTS:
 	void createContextMenu(QMenu*);
 	void run();
@@ -48,6 +52,8 @@ private:
 	KTextEditor::View* m_kTextEditorView{nullptr};
 	QAction* m_runScriptAction{nullptr};
 	QAction* m_clearOutputAction{nullptr};
+	QAction* m_copySelectedAction{nullptr};
+	QAction* m_copyAllOutputAction{nullptr};
 
 	void initActions();
 	void initMenus();
@@ -55,5 +61,8 @@ private:
 	QByteArray splitterState();
 	void setOutputFont(const QFont&);
 	QFont outputFont();
+
+	QString processOutputText(bool isErr, const QString& text);
+	void applyOutputFormatting(const QString& html, bool isErr);
 };
 #endif
diff --git a/src/frontend/ui/script/scripteditorwidget.ui b/src/frontend/ui/script/scripteditorwidget.ui
index ca5b602c7e..b714ecbb7f 100644
--- a/src/frontend/ui/script/scripteditorwidget.ui
+++ b/src/frontend/ui/script/scripteditorwidget.ui
@@ -99,13 +99,16 @@
         </widget>
        </item>
        <item>
-        <widget class="QPlainTextEdit" name="output">
+        <widget class="QTextBrowser" name="output">
          <property name="sizePolicy">
           <sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
            <horstretch>0</horstretch>
            <verstretch>0</verstretch>
           </sizepolicy>
          </property>
+         <property name="openLinks">
+          <bool>false</bool>
+         </property>
         </widget>
        </item>
       </layout>
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.