Files
create/src/Gui/PythonConsole.cpp
2025-11-11 13:49:01 +01:00

1704 lines
54 KiB
C++

/***************************************************************************
* Copyright (c) 2004 Werner Mayer <wmayer[at]users.sourceforge.net> *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
***************************************************************************/
#include <QApplication>
#include <QClipboard>
#include <QDockWidget>
#include <QKeyEvent>
#include <QMenu>
#include <QMessageBox>
#include <QMimeData>
#include <QTextCursor>
#include <QTextDocumentFragment>
#include <QTextStream>
#include <QTime>
#include <QUrl>
#include <Base/Interpreter.h>
#include <Base/Color.h>
#include "PythonConsole.h"
#include "PythonConsolePy.h"
#include "PythonTracing.h"
#include "Application.h"
#include "FileDialog.h"
#include "MainWindow.h"
#include "Tools.h"
using namespace Gui;
namespace Gui
{
static const QChar promptEnd(QLatin1Char(' ')); //< char for detecting prompt end
inline int promptLength(const QString& lineStr)
{
return lineStr.indexOf(promptEnd) + 1;
}
inline QString stripPromptFrom(const QString& lineStr)
{
return lineStr.mid(promptLength(lineStr));
}
/**
* cursorBeyond checks if cursor is at a valid position to accept keyEvents.
* @param cursor - cursor to check
* @param limit - cursor that marks the begin of the input region
* @param shift - offset for shifting the limit for non-selection cursors [default: 0]
* @return true if a keyEvent is ok at cursor's position, false otherwise
*/
inline bool cursorBeyond(const QTextCursor& cursor, const QTextCursor& limit, int shift = 0)
{
int pos = limit.position();
if (cursor.hasSelection()) {
return (cursor.selectionStart() >= pos && cursor.selectionEnd() >= pos);
}
return cursor.position() >= (pos + shift);
}
struct PythonConsoleP
{
enum Output
{
Error = 20,
Message = 21
};
enum CopyType
{
Normal,
History,
Command
};
CopyType type;
PyObject *_stdoutPy = nullptr, *_stderrPy = nullptr, *_stdinPy = nullptr, *_stdin = nullptr;
InteractiveInterpreter* interpreter = nullptr;
ConsoleHistory history;
QString output, error, info, historyFile;
QStringList statements;
bool interactive;
ParameterGrp::handle hGrpSettings;
PythonConsoleP()
{
type = Normal;
interactive = false;
historyFile = QString::fromUtf8(
(App::Application::getUserAppDataDir() + "PythonHistory.log").c_str()
);
}
};
struct InteractiveInterpreterP
{
PyObject* interpreter {nullptr};
PyObject* sysmodule {nullptr};
QStringList buffer;
PythonTracing trace;
};
} // namespace Gui
InteractiveInterpreter::InteractiveInterpreter()
{
// import code.py and create an instance of InteractiveInterpreter
Base::PyGILStateLocker lock;
PyObject* module = PyImport_ImportModule("code");
if (!module) {
throw Base::PyException();
}
PyObject* func = PyObject_GetAttrString(module, "InteractiveInterpreter");
PyObject* args = Py_BuildValue("()");
d = new InteractiveInterpreterP;
d->interpreter = PyObject_CallObject(func, args);
Py_DECREF(args);
Py_DECREF(func);
Py_DECREF(module);
setPrompt();
}
InteractiveInterpreter::~InteractiveInterpreter()
{
Base::PyGILStateLocker lock;
Py_XDECREF(d->interpreter);
Py_XDECREF(d->sysmodule);
delete d;
}
/**
* Set the ps1 and ps2 members of the sys module if not yet defined.
*/
void InteractiveInterpreter::setPrompt()
{
// import code.py and create an instance of InteractiveInterpreter
Base::PyGILStateLocker lock;
d->sysmodule = PyImport_ImportModule("sys");
if (!PyObject_HasAttrString(d->sysmodule, "ps1")) {
PyObject_SetAttrString(d->sysmodule, "ps1", PyUnicode_FromString(">>> "));
}
if (!PyObject_HasAttrString(d->sysmodule, "ps2")) {
PyObject_SetAttrString(d->sysmodule, "ps2", PyUnicode_FromString("... "));
}
}
/**
* Compile a command and determine whether it is incomplete.
*
* The source string may contain line feeds and/or carriage returns. \n
* Return value / exceptions raised:
* - Return a code object if the command is complete and valid
* - Return None if the command is incomplete
* - Raise SyntaxError, ValueError or OverflowError if the command is a
* syntax error (OverflowError and ValueError can be produced by
* malformed literals).
*/
PyObject* InteractiveInterpreter::compile(const char* source) const
{
Base::PyGILStateLocker lock;
PyObject* func = PyObject_GetAttrString(d->interpreter, "compile");
PyObject* args = Py_BuildValue("(s)", source);
PyObject* eval = PyObject_CallObject(func, args); // must decref later
Py_XDECREF(args);
Py_XDECREF(func);
if (eval) {
return eval;
}
else {
// do not throw Base::PyException as this clears the error indicator
throw Base::RuntimeError("Code evaluation failed");
}
// can never happen
return nullptr;
}
/**
* Compile a command and determine whether it is incomplete.
*
* The source string may contain line feeds and/or carriage returns. \n
* Return value:
* - Return 1 if the command is incomplete
* - Return 0 if the command is complete and valid
* - Return -1 if the command is a syntax error
* .
* (OverflowError and ValueError can be produced by malformed literals).
*/
int InteractiveInterpreter::compileCommand(const char* source) const
{
Base::PyGILStateLocker lock;
PyObject* func = PyObject_GetAttrString(d->interpreter, "compile");
PyObject* args = Py_BuildValue("(s)", source);
PyObject* eval = PyObject_CallObject(func, args); // must decref later
Py_DECREF(args);
Py_DECREF(func);
int ret = 0;
if (eval) {
if (PyObject_TypeCheck(Py_None, eval->ob_type)) {
ret = 1; // incomplete
}
else {
ret = 0; // complete
}
Py_DECREF(eval);
}
else {
ret = -1; // invalid
}
return ret;
}
/**
* Compile and run some source in the interpreter.
*
* One several things can happen:
*
* - The input is incorrect; compile() raised an exception (SyntaxError or OverflowError).
* A syntax traceback will be printed by calling Python's PyErr_Print() method to the redirected
* stderr.
*
* - The input is incomplete, and more input is required; compile() returned 'None'.
* Nothing happens.
*
* - The input is complete; compile() returned a code object. The code is executed by calling
* runCode() (which also handles run-time exceptions, except for SystemExit).
* .
* The return value is True if the input is incomplete, False in the other cases (unless
* an exception is raised). The return value can be used to decide whether to use sys.ps1
* or sys.ps2 to prompt the next line.
*/
bool InteractiveInterpreter::runSource(const char* source) const
{
Base::PyGILStateLocker lock;
PyObject* code;
try {
code = compile(source);
}
catch (const Base::Exception&) {
// A system, overflow or value error was raised.
// We clear the traceback info as this might be a longly
// message we don't need.
PyObject *errobj, *errdata, *errtraceback;
PyErr_Fetch(&errobj, &errdata, &errtraceback);
PyErr_Restore(errobj, errdata, nullptr);
// print error message
if (PyErr_Occurred()) {
PyErr_Print();
}
return false;
}
// the command is incomplete
if (PyObject_TypeCheck(Py_None, code->ob_type)) {
Py_DECREF(code);
return true;
}
// run the code and return false
runCode((PyCodeObject*)code);
return false;
}
bool InteractiveInterpreter::isOccupied() const
{
return d->trace.isActive();
}
bool InteractiveInterpreter::interrupt() const
{
return d->trace.interrupt();
}
/* Execute a code object.
*
* When an exception occurs, a traceback is displayed.
* All exceptions are caught except SystemExit, which is reraised.
*/
void InteractiveInterpreter::runCode(PyCodeObject* code) const
{
if (isOccupied()) {
return;
}
d->trace.fetchFromSettings();
PythonTracingLocker tracelock(d->trace);
Base::PyGILStateLocker lock;
PyObject *module, *dict, *presult; /* "exec code in d, d" */
module = PyImport_AddModule("__main__"); /* get module, init python */
if (!module) {
throw Base::PyException(); /* not incref'd */
}
dict = PyModule_GetDict(module); /* get dict namespace */
if (!dict) {
throw Base::PyException(); /* not incref'd */
}
// It seems that the return value is always 'None' or Null
presult = PyEval_EvalCode((PyObject*)code, dict, dict); /* run compiled bytecode */
Py_XDECREF(code); /* decref the code object */
if (!presult) {
if (PyErr_ExceptionMatches(PyExc_SystemExit)) {
// throw SystemExit exception
throw Base::SystemExitException();
}
if (PyErr_Occurred()) { /* get latest python exception information */
PyObject *errobj, *errdata, *errtraceback;
PyErr_Fetch(&errobj, &errdata, &errtraceback);
// the error message can be empty so errdata will be null
if (errdata && PyDict_Check(errdata)) {
PyObject* value = PyDict_GetItemString(errdata, "swhat");
if (value) {
Base::RuntimeError e;
e.setPyObject(errdata);
Py_DECREF(errdata);
std::stringstream str;
str << e.what();
if (!e.getFunction().empty()) {
str << " In " << e.getFunction();
}
if (!e.getFile().empty() && e.getLine() > 0) {
std::string file = e.getFile();
std::size_t pos = file.find("src");
if (pos != std::string::npos) {
file = file.substr(pos);
}
str << " in " << file << ":" << e.getLine();
}
std::string err = str.str();
errdata = PyUnicode_FromString(err.c_str());
}
}
PyErr_Restore(errobj, errdata, errtraceback);
PyErr_Print(); /* and print the error to the error output */
}
}
else {
Py_DECREF(presult);
}
}
/**
* Store the line into the internal buffer and compile the total buffer.
* In case it is a complete Python command the buffer is emptied.
*/
bool InteractiveInterpreter::push(const char* line)
{
d->buffer.append(QString::fromUtf8(line));
QString source = d->buffer.join(QLatin1String("\n"));
try {
bool more = runSource(source.toUtf8());
if (!more) {
d->buffer.clear();
}
return more;
}
catch (const Base::SystemExitException&) {
d->buffer.clear();
throw;
}
catch (...) {
// indication of unhandled exception
d->buffer.clear();
if (PyErr_Occurred()) {
PyErr_Print();
}
throw;
}
return false;
}
bool InteractiveInterpreter::hasPendingInput() const
{
return (!d->buffer.isEmpty());
}
QStringList InteractiveInterpreter::getBuffer() const
{
return d->buffer;
}
void InteractiveInterpreter::setBuffer(const QStringList& buf)
{
d->buffer = buf;
}
void InteractiveInterpreter::clearBuffer()
{
d->buffer.clear();
}
/* TRANSLATOR Gui::PythonConsole */
/**
* Constructs a PythonConsole which is a child of 'parent'.
*/
PythonConsole::PythonConsole(QWidget* parent)
: PythonTextEditor(parent)
, _sourceDrain(nullptr)
{
d = new PythonConsoleP();
d->interactive = false;
// create an instance of InteractiveInterpreter
try {
d->interpreter = new InteractiveInterpreter();
}
catch (const Base::Exception& e) {
setPlainText(QString::fromLatin1(e.what()));
setEnabled(false);
}
// use the console highlighter
pythonSyntax = new PythonConsoleHighlighter(this);
setSyntaxHighlighter(pythonSyntax);
setVisibleLineNumbers(false);
setEnabledHighlightCurrentLine(false);
QFont serifFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
serifFont.setPointSize(10);
setFont(serifFont);
// set colors and font from settings
ParameterGrp::handle hPrefGrp = getWindowParameter();
hPrefGrp->NotifyAll();
d->hGrpSettings = WindowParameter::getDefaultParameter()->GetGroup("PythonConsole");
d->hGrpSettings->Attach(this);
d->hGrpSettings->NotifyAll();
// disable undo/redo stuff
setUndoRedoEnabled(false);
setAcceptDrops(true);
// try to override Python's stdout/err
Base::PyGILStateLocker lock;
d->_stdoutPy = new PythonStdout(this);
d->_stderrPy = new PythonStderr(this);
d->_stdinPy = new PythonStdin(this);
d->_stdin = PySys_GetObject("stdin");
// Don't override stdin when running FreeCAD as Python module
auto& cfg = App::Application::Config();
auto overrideStdIn = cfg.find("DontOverrideStdIn");
if (overrideStdIn == cfg.end()) {
PySys_SetObject("stdin", d->_stdinPy);
}
const char* version = PyUnicode_AsUTF8(PySys_GetObject("version"));
const char* platform = PyUnicode_AsUTF8(PySys_GetObject("platform"));
d->info = QStringLiteral(
"Python %1 on %2\n"
"Type 'help', 'copyright', 'credits' or 'license' for more information."
)
.arg(QString::fromLatin1(version), QString::fromLatin1(platform));
d->output = d->info;
printPrompt(PythonConsole::Complete);
loadHistory();
flusher = new QTimer(this);
connect(flusher, &QTimer::timeout, this, &PythonConsole::flushOutput);
flusher->start(100);
}
/** Destroys the object and frees any allocated resources */
PythonConsole::~PythonConsole()
{
saveHistory();
Base::PyGILStateLocker lock;
d->hGrpSettings->Detach(this);
Py_XDECREF(d->_stdoutPy);
Py_XDECREF(d->_stderrPy);
Py_XDECREF(d->_stdinPy);
delete d->interpreter;
delete d;
}
/** Set new font and colors according to the parameters. */
void PythonConsole::OnChange(Base::Subject<const char*>& rCaller, const char* sReason)
{
const auto& rGrp = static_cast<ParameterGrp&>(rCaller);
if (strcmp(sReason, "PythonWordWrap") == 0) {
bool pythonWordWrap = rGrp.GetBool("PythonWordWrap", true);
if (pythonWordWrap) {
setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
}
else {
setWordWrapMode(QTextOption::NoWrap);
}
}
if (strcmp(sReason, "PythonBlockCursor") == 0) {
bool block = rGrp.GetBool("PythonBlockCursor", false);
if (block) {
setCursorWidth(QFontMetrics(font()).averageCharWidth());
}
else {
setCursorWidth(1);
}
}
if (strcmp(sReason, "EnableLineNumber") != 0) {
TextEditor::OnChange(rCaller, sReason);
}
}
int PythonConsole::getInputStringPosition()
{
QString rawLine = textCursor().block().text();
return textCursor().positionInBlock() - promptLength(rawLine);
}
QString PythonConsole::getInputString()
{
QString rawLine = textCursor().block().text();
return stripPromptFrom(rawLine);
}
/**
* Checks the input of the console to make the correct indentations.
* After a command is prompted completely the Python interpreter is started.
*/
void PythonConsole::keyPressEvent(QKeyEvent* e)
{
bool restartHistory = true;
QTextCursor cursor = this->textCursor();
QTextCursor inputLineBegin = this->inputBegin();
if (e->key() == Qt::Key_C && e->modifiers() == Qt::ControlModifier) {
if (d->interpreter->interrupt()) {
return;
}
}
if (!cursorBeyond(cursor, inputLineBegin)) {
/**
* The cursor is placed not on the input line (or within the prompt string)
* So we handle key input as follows:
* - don't allow changing previous lines.
* - allow full movement (no prompt restriction)
* - allow copying content (Ctrl+C)
* - "escape" to end of input line
*/
switch (e->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
case Qt::Key_Escape:
case Qt::Key_Backspace:
this->moveCursor(QTextCursor::End);
break;
default:
if (e->text().isEmpty() || e->matches(QKeySequence::Copy)
|| e->matches(QKeySequence::SelectAll)) {
PythonTextEditor::keyPressEvent(e);
}
else if (!e->text().isEmpty()
&& (e->modifiers() == Qt::NoModifier || e->modifiers() == Qt::ShiftModifier)) {
this->moveCursor(QTextCursor::End);
PythonTextEditor::keyPressEvent(e);
}
break;
}
}
else {
/**
* The cursor sits somewhere on the input line (after the prompt)
* Here we handle key input a bit different:
* - restrict cursor movement to input line range (excluding the prompt characters)
* - roam the history by Up/Down keys
* - show call tips on period
*/
QTextBlock inputBlock = inputLineBegin.block(); //< get the last paragraph's text
QString inputLine = inputBlock.text();
QString inputStrg = stripPromptFrom(inputLine);
if (this->_sourceDrain && !this->_sourceDrain->isEmpty()) {
inputStrg = inputLine.mid(this->_sourceDrain->length());
}
switch (e->key()) {
case Qt::Key_Escape: {
// disable current input string - i.e. put it to history but don't execute it.
if (!inputStrg.isEmpty()) {
d->history.append(QLatin1String("# ") + inputStrg); //< put commented string to
// history ...
inputLineBegin.insertText(QStringLiteral("# ")); //< and comment it on console
setTextCursor(inputLineBegin);
printPrompt(
d->interpreter->hasPendingInput() //< print adequate prompt
? PythonConsole::Incomplete
: PythonConsole::Complete
);
}
} break;
case Qt::Key_Return:
case Qt::Key_Enter: {
d->history.append(inputStrg); //< put statement to history
runSource(inputStrg); //< commit input string
} break;
case Qt::Key_Home: {
QTextCursor::MoveMode mode = (e->modifiers() & Qt::ShiftModifier)
? QTextCursor::KeepAnchor
/* else */
: QTextCursor::MoveAnchor;
cursor.setPosition(inputLineBegin.position(), mode);
setTextCursor(cursor);
ensureCursorVisible();
} break;
case Qt::Key_Up: {
// if possible, move back in history
if (d->history.prev(inputStrg)) {
overrideCursor(d->history.value());
}
restartHistory = false;
} break;
case Qt::Key_Down: {
// if possible, move forward in history
if (d->history.next()) {
overrideCursor(d->history.value());
}
restartHistory = false;
} break;
case Qt::Key_Left: {
if (cursor > inputLineBegin) {
PythonTextEditor::keyPressEvent(e);
}
restartHistory = false;
} break;
case Qt::Key_Right: {
PythonTextEditor::keyPressEvent(e);
restartHistory = false;
} break;
case Qt::Key_Backspace: {
if (cursorBeyond(cursor, inputLineBegin, +1)) {
PythonTextEditor::keyPressEvent(e);
}
} break;
default: {
PythonTextEditor::keyPressEvent(e);
} break;
}
// disable history restart if input line changed
restartHistory &= (inputLine != inputBlock.text());
}
// any cursor move resets the history to its latest item.
if (restartHistory) {
d->history.restart();
}
}
/**
* Insert an output message to the console. This message comes from
* the Python interpreter and is redirected from sys.stdout.
*/
void PythonConsole::insertPythonOutput(const QString& msg)
{
d->output += msg;
}
/**
* Insert an error message to the console. This message comes from
* the Python interpreter and is redirected from sys.stderr.
*/
void PythonConsole::insertPythonError(const QString& err)
{
d->error += err;
}
void PythonConsole::onFlush()
{
printPrompt(PythonConsole::Flush);
}
void PythonConsole::flushOutput()
{
if (d->interpreter->isOccupied()) {
if (d->output.length() > 0 || d->error.length() > 0) {
printPrompt(PythonConsole::Complete);
}
}
}
/** Prints the ps1 prompt (>>> ) for complete and ps2 prompt (... ) for
* incomplete commands to the console window.
*/
void PythonConsole::printPrompt(PythonConsole::Prompt mode)
{
// write normal messages
if (!d->output.isEmpty()) {
appendOutput(d->output, (int)PythonConsoleP::Message);
d->output.clear();
}
// write error messages
if (!d->error.isEmpty()) {
appendOutput(d->error, (int)PythonConsoleP::Error);
d->error.clear();
}
// Append the prompt string
QTextCursor cursor = textCursor();
if (mode != PythonConsole::Special) {
cursor.beginEditBlock();
cursor.movePosition(QTextCursor::End);
QTextBlock block = cursor.block();
// Python's print command appends a trailing '\n' to the system output.
// In this case, however, we should not add a new text block. We force
// the current block to be normal text (user state = 0) to be highlighted
// correctly and append the '>>> ' or '... ' to this block.
if (block.length() > 1) {
cursor.insertBlock(cursor.blockFormat(), cursor.charFormat());
}
else {
block.setUserState(0);
}
switch (mode) {
case PythonConsole::Incomplete:
cursor.insertText(QStringLiteral("... "));
break;
case PythonConsole::Complete:
cursor.insertText(QStringLiteral(">>> "));
break;
default:
break;
}
cursor.endEditBlock();
}
// move cursor to the end
cursor.movePosition(QTextCursor::End);
setTextCursor(cursor);
}
/**
* Appends \a output to the console and set \a state as user state to
* the text block which is needed for the highlighting.
*/
void PythonConsole::appendOutput(const QString& output, int state)
{
QTextCursor cursor = textCursor();
cursor.movePosition(QTextCursor::End);
int pos = cursor.position() + 1;
// delay rehighlighting
cursor.beginEditBlock();
appendPlainText(output);
QTextBlock block = this->document()->findBlock(pos);
while (block.isValid()) {
block.setUserState(state);
block = block.next();
}
cursor.endEditBlock(); // start highlightiong
}
/**
* Builds up the Python command and pass it to the interpreter.
*/
void PythonConsole::runSource(const QString& line)
{
/**
* Check if there's a "source drain", which wants to consume the source in another way then just
* executing it. If so, put the source to the drain and emit a signal to notify the consumer,
* whomever this may be.
*/
if (this->_sourceDrain) {
*this->_sourceDrain = line;
Q_EMIT pendingSource();
return;
}
if (d->interpreter->isOccupied()) {
insertPythonError(QStringLiteral("Previous command still running!"));
return;
}
bool incomplete = false;
Base::PyGILStateLocker lock;
PyObject* default_stdout = PySys_GetObject("stdout");
PyObject* default_stderr = PySys_GetObject("stderr");
PySys_SetObject("stdout", d->_stdoutPy);
PySys_SetObject("stderr", d->_stderrPy);
d->interactive = true;
try {
d->history.markScratch(); //< mark current history position ...
// launch the command now
incomplete = d->interpreter->push(line.toUtf8());
if (!incomplete) {
d->history.doScratch();
} //< ... and scratch history entries that might have been added by executing the line.
setFocus(); // if focus was lost
}
catch (const Base::SystemExitException&) {
// In Python the exception must be cleared because when the message box below appears
// callable Python objects can be invoked and due to a failing assert the application
// will be aborted.
PyErr_Clear();
ParameterGrp::handle hPrefGrp = getWindowParameter();
bool check = hPrefGrp->GetBool("CheckSystemExit", true);
int ret = QMessageBox::Yes;
if (check) {
ret = QMessageBox::question(
this,
tr("System exit"),
tr("The application is still running.\nExit without saving all data?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No
);
}
if (ret == QMessageBox::Yes) {
PyErr_Clear();
throw;
}
else {
PyErr_Clear();
}
}
catch (const Py::Exception&) {
QMessageBox::critical(this, tr("Python Console"), tr("Unhandled PyCXX exception."));
}
catch (const Base::Exception&) {
QMessageBox::critical(this, tr("Python Console"), tr("Unhandled FreeCAD exception."));
}
catch (const std::exception&) {
QMessageBox::critical(this, tr("Python Console"), tr("Unhandled std C++ exception."));
}
catch (...) {
QMessageBox::critical(this, tr("Python Console"), tr("Unhandled unknown C++ exception."));
}
printPrompt(incomplete ? PythonConsole::Incomplete : PythonConsole::Complete);
PySys_SetObject("stdout", default_stdout);
PySys_SetObject("stderr", default_stderr);
d->interactive = false;
for (const auto& it : d->statements) {
printStatement(it);
}
d->statements.clear();
}
bool PythonConsole::isComment(const QString& source) const
{
if (source.isEmpty()) {
return false;
}
int i = 0;
while (i < source.length()) {
QChar ch = source.at(i++);
if (ch.isSpace()) {
continue;
}
else if (ch == QLatin1Char('#')) {
return true;
}
else {
return false;
}
}
return false;
}
/**
* Prints the Python statement cmd to the console.
* @note The statement gets only printed and added to the history but not invoked.
*/
void PythonConsole::printStatement(const QString& cmd)
{
// If we are in interactive mode we have to wait until the command is finished,
// afterwards we can print the statements.
if (d->interactive) {
d->statements << cmd;
return;
}
QTextCursor cursor = textCursor();
QStringList statements = cmd.split(QLatin1String("\n"));
for (const auto& statement : statements) {
// go to the end before inserting new text
cursor.movePosition(QTextCursor::End);
cursor.insertText(statement);
d->history.append(statement);
printPrompt(PythonConsole::Complete);
}
}
/**
* Shows the Python window and sets the focus to set text cursor.
*/
void PythonConsole::showEvent(QShowEvent* e)
{
TextEdit::showEvent(e);
// set also the text cursor to the edit field
setFocus();
}
void PythonConsole::visibilityChanged(bool visible)
{
if (visible) {
setFocus();
}
}
void PythonConsole::changeEvent(QEvent* e)
{
if (e->type() == QEvent::ParentChange) {
auto dw = qobject_cast<QDockWidget*>(this->parentWidget());
if (dw) {
connect(dw, &QDockWidget::visibilityChanged, this, &PythonConsole::visibilityChanged);
}
}
else if (e->type() == QEvent::StyleChange) {
QPalette pal = qApp->palette();
QColor color = pal.windowText().color();
unsigned int text = Base::Color::asPackedRGB<QColor>(color);
auto value = static_cast<unsigned long>(text);
// if this parameter is not already set use the style's window text color
value = getWindowParameter()->GetUnsigned("Text", value);
getWindowParameter()->SetUnsigned("Text", value);
}
TextEdit::changeEvent(e);
}
void PythonConsole::mouseReleaseEvent(QMouseEvent* e)
{
if (e->button() == Qt::MiddleButton && e->spontaneous()) {
// on Linux-like systems the middle mouse button is typically connected to a paste operation
// which will insert some text at the mouse position
QTextCursor cursor = this->textCursor();
if (cursor < this->inputBegin()) {
cursor.movePosition(QTextCursor::End);
this->setTextCursor(cursor);
}
// the text will be pasted at the cursor position (as for Ctrl-V operation)
QRect newPos = this->cursorRect();
// Now we must amend the received event and pass forward. As e->setLocalPos() is only
// available in Qt>=5.8, let's stop the original event propagation and generate a fake event
// with corrected pointer position (inside the prompt line of the widget)
#if QT_VERSION < QT_VERSION_CHECK(6, 4, 0)
QMouseEvent newEv(
e->type(),
QPoint(newPos.x(), newPos.y()),
e->button(),
e->buttons(),
e->modifiers()
);
#else
QMouseEvent newEv(
e->type(),
QPoint(newPos.x(), newPos.y()),
e->globalPosition(),
e->button(),
e->buttons(),
e->modifiers()
);
#endif
e->accept();
QCoreApplication::sendEvent(this->viewport(), &newEv);
return;
}
TextEdit::mouseReleaseEvent(e);
}
/**
* Drops the event \a e and writes the right Python command.
*/
void PythonConsole::dropEvent(QDropEvent* e)
{
const QMimeData* mimeData = e->mimeData();
if (mimeData->hasFormat(QLatin1String("text/x-action-items"))) {
QByteArray itemData = mimeData->data(QLatin1String("text/x-action-items"));
QDataStream dataStream(&itemData, QIODevice::ReadOnly);
int ctActions;
dataStream >> ctActions;
for (int i = 0; i < ctActions; i++) {
QString action;
dataStream >> action;
printStatement(QStringLiteral("Gui.runCommand(\"%1\")").arg(action));
}
e->setDropAction(Qt::CopyAction);
e->accept();
}
else {
// always copy text when doing drag and drop
if (mimeData->hasText()) {
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QTextCursor cursor = this->cursorForPosition(e->pos());
#else
QTextCursor cursor = this->cursorForPosition(e->position().toPoint());
#endif
QTextCursor inputLineBegin = this->inputBegin();
if (!cursorBeyond(cursor, inputLineBegin)) {
this->moveCursor(QTextCursor::End);
QRect newPos = this->cursorRect();
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QDropEvent newEv(
QPoint(newPos.x(), newPos.y()),
Qt::CopyAction,
mimeData,
e->mouseButtons(),
e->keyboardModifiers()
);
#else
QDropEvent newEv(
QPoint(newPos.x(), newPos.y()),
Qt::CopyAction,
mimeData,
e->buttons(),
e->modifiers()
);
#endif
e->accept();
QPlainTextEdit::dropEvent(&newEv);
}
else {
e->setDropAction(Qt::CopyAction);
QPlainTextEdit::dropEvent(e);
}
}
else {
// this will call insertFromMimeData
QPlainTextEdit::dropEvent(e);
}
}
}
/** Dragging of action objects is allowed. */
void PythonConsole::dragMoveEvent(QDragMoveEvent* e)
{
const QMimeData* mimeData = e->mimeData();
if (mimeData->hasFormat(QLatin1String("text/x-action-items"))) {
e->accept();
}
else {
// this will call canInsertFromMimeData
QPlainTextEdit::dragMoveEvent(e);
}
}
/** Dragging of action objects is allowed. */
void PythonConsole::dragEnterEvent(QDragEnterEvent* e)
{
const QMimeData* mimeData = e->mimeData();
if (mimeData->hasFormat(QLatin1String("text/x-action-items"))) {
e->accept();
}
else {
// this will call canInsertFromMimeData
QPlainTextEdit::dragEnterEvent(e);
}
}
bool PythonConsole::canInsertFromMimeData(const QMimeData* source) const
{
if (source->hasText()) {
return true;
}
if (source->hasUrls()) {
QList<QUrl> uri = source->urls();
for (const auto& it : uri) {
QFileInfo info(it.toLocalFile());
if (info.exists() && info.isFile()) {
QString ext = info.suffix().toLower();
if (ext == QLatin1String("py") || ext == QLatin1String("fcmacro")) {
return true;
}
}
}
}
return false;
}
/**
* Allow one to paste plain text or urls of text files.
*/
void PythonConsole::insertFromMimeData(const QMimeData* source)
{
if (!source) {
return;
}
// First check on urls instead of text otherwise it may happen that a url
// is handled as text
bool existingFile = false;
if (source->hasUrls()) {
QList<QUrl> uri = source->urls();
for (const auto& it : uri) {
// get the file name and check the extension
QFileInfo info(it.toLocalFile());
QString ext = info.suffix().toLower();
if (info.exists()) {
existingFile = true;
if (info.isFile() && (ext == QLatin1String("py") || ext == QLatin1String("fcmacro"))) {
// load the file and read-in the source code
QFile file(info.absoluteFilePath());
if (file.open(QIODevice::ReadOnly)) {
QTextStream str(&file);
runSourceFromMimeData(str.readAll());
}
file.close();
}
}
}
}
// Some applications copy text into the clipboard with the formats
// 'text/plain' and 'text/uri-list'. In case the url is not an existing
// file we can handle it as normal text, then. See forum thread:
// https://forum.freecad.org/viewtopic.php?f=3&t=34618
if (source->hasText() && !existingFile) {
runSourceFromMimeData(source->text());
}
}
QTextCursor PythonConsole::inputBegin() const
{
// construct cursor at begin of input line ...
QTextCursor inputLineBegin(this->textCursor());
inputLineBegin.movePosition(QTextCursor::End);
inputLineBegin.movePosition(QTextCursor::StartOfBlock);
// ... and move cursor right beyond the prompt.
int prompt = promptLength(inputLineBegin.block().text());
if (this->_sourceDrain && !this->_sourceDrain->isEmpty()) {
prompt = this->_sourceDrain->length();
}
inputLineBegin.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, prompt);
return inputLineBegin;
}
QMimeData* PythonConsole::createMimeDataFromSelection() const
{
auto mime = new QMimeData();
switch (d->type) {
case PythonConsoleP::Normal: {
const QTextDocumentFragment fragment(textCursor());
mime->setText(fragment.toPlainText());
} break;
case PythonConsoleP::Command: {
QTextCursor cursor = textCursor();
int s = cursor.selectionStart();
int e = cursor.selectionEnd();
QTextBlock b;
QStringList lines;
for (b = document()->begin(); b.isValid(); b = b.next()) {
int pos = b.position();
if (pos >= s && pos <= e) {
if (b.userState() > -1 && b.userState() < pythonSyntax->maximumUserState()) {
lines << stripPromptFrom(b.text());
}
}
}
QString text = lines.join(QLatin1String("\n"));
mime->setText(text);
} break;
case PythonConsoleP::History: {
const QStringList& hist = d->history.values();
QString text = hist.join(QLatin1String("\n"));
mime->setText(text);
} break;
}
return mime;
}
void PythonConsole::runSourceFromMimeData(const QString& source)
{
// When inserting a big text block we must break it down into several command
// blocks instead of processing the text block as a whole or each single line.
// If we processed the complete block as a whole only the first valid Python
// command would be executed and the rest would be ignored. However, if we
// processed each line separately the interpreter might be confused that a block
// is complete but it might be not. This is for instance, if a class or method
// definition contains several empty lines which leads to error messages (almost
// indentation errors) later on.
QString text = source;
if (text.isNull()) {
return;
}
#if defined(Q_OS_LINUX)
// Need to convert CRLF to LF
text.replace(QLatin1String("\r\n"), QLatin1String("\n"));
#elif defined(Q_OS_WIN32)
// Need to convert CRLF to LF
text.replace(QLatin1String("\r\n"), QLatin1String("\n"));
#elif defined(Q_OS_MACOS)
// need to convert CR to LF
text.replace(QLatin1Char('\r'), QLatin1Char('\n'));
#endif
// separate the lines and get the last one
QStringList lines = text.split(QLatin1Char('\n'));
QString last = lines.back();
lines.pop_back();
QTextCursor cursor = textCursor();
QStringList buffer = d->interpreter->getBuffer();
d->interpreter->clearBuffer();
int countNewlines = lines.count(), i = 0;
for (QStringList::Iterator it = lines.begin(); it != lines.end(); ++it, ++i) {
QString line = *it;
// insert the text to the current cursor position
cursor.insertText(*it);
// for the very first line get the complete block
// because it may differ from the inserted text
if (i == 0) {
// get the text from the current cursor position to the end, remove it
// and add it to the last line
cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
QString select = cursor.selectedText();
cursor.removeSelectedText();
last = last + select;
line = stripPromptFrom(cursor.block().text());
}
// put statement to the history
d->history.append(line);
buffer.append(line);
int ret = d->interpreter->compileCommand(buffer.join(QLatin1String("\n")).toUtf8());
if (ret == 1) { // incomplete
printPrompt(PythonConsole::Incomplete);
}
else if (ret == 0) { // complete
// check if the following lines belong to the previous block
int k = i + 1;
QString nextline;
while ((nextline.isEmpty() || isComment(nextline)) && k < countNewlines) {
nextline = lines[k];
k++;
}
int ret = d->interpreter->compileCommand(nextline.toUtf8());
// If the line is valid, i.e. complete or incomplete the previous block
// is finished
if (ret == -1) {
// the command is not finished yet
printPrompt(PythonConsole::Incomplete);
}
else {
runSource(buffer.join(QLatin1String("\n")));
buffer.clear();
}
}
else { // invalid
runSource(buffer.join(QLatin1String("\n")));
ensureCursorVisible();
return; // exit the method on error
}
}
// set the incomplete block to the interpreter and insert the last line
d->interpreter->setBuffer(buffer);
cursor.insertText(last);
ensureCursorVisible();
}
/**
* Overwrites the text of the cursor.
*/
void PythonConsole::overrideCursor(const QString& txt)
{
// Go to the last line and the fourth position, right after the prompt
QTextCursor cursor = this->inputBegin();
int blockLength = this->textCursor().block().text().length();
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, blockLength); //<< select text
// to override
cursor.removeSelectedText();
cursor.insertText(txt);
// move cursor to the end
cursor.movePosition(QTextCursor::End);
setTextCursor(cursor);
}
void PythonConsole::contextMenuEvent(QContextMenuEvent* e)
{
QMenu menu(this);
QAction* a;
bool mayPasteHere = cursorBeyond(this->textCursor(), this->inputBegin());
a = menu.addAction(tr("&Copy"), this, &PythonConsole::copy);
a->setShortcut(QKeySequence(QStringLiteral("CTRL+C")));
a->setEnabled(textCursor().hasSelection());
a = menu.addAction(tr("&Copy Command"), this, &PythonConsole::onCopyCommand);
a->setEnabled(textCursor().hasSelection());
a = menu.addAction(tr("&Copy History"), this, &PythonConsole::onCopyHistory);
a->setEnabled(!d->history.isEmpty());
a = menu.addAction(tr("Save History As…"), this, &PythonConsole::onSaveHistoryAs);
a->setEnabled(!d->history.isEmpty());
QAction* saveh = menu.addAction(tr("Save History"));
saveh->setToolTip(tr("Saves Python history across %1 sessions").arg(qApp->applicationName()));
saveh->setCheckable(true);
saveh->setChecked(d->hGrpSettings->GetBool("SavePythonHistory", false));
menu.addSeparator();
a = menu.addAction(tr("&Paste"), this, &PythonConsole::paste);
a->setShortcut(QKeySequence(QStringLiteral("CTRL+V")));
const QMimeData* md = QApplication::clipboard()->mimeData();
a->setEnabled(mayPasteHere && md && canInsertFromMimeData(md));
a = menu.addAction(tr("Select All"), this, &PythonConsole::selectAll);
a->setShortcut(QKeySequence(QStringLiteral("CTRL+A")));
a->setEnabled(!document()->isEmpty());
a = menu.addAction(tr("Clear Console"), this, &PythonConsole::onClearConsole);
a->setEnabled(!document()->isEmpty());
menu.addSeparator();
menu.addAction(tr("Insert File Name…"), this, &PythonConsole::onInsertFileName);
menu.addSeparator();
QAction* wrap = menu.addAction(tr("Word Wrap"));
wrap->setCheckable(true);
wrap->setChecked(d->hGrpSettings->GetBool("PythonWordWrap", true));
QAction* exec = menu.exec(e->globalPos());
if (exec == wrap) {
d->hGrpSettings->SetBool("PythonWordWrap", wrap->isChecked());
}
else if (exec == saveh) {
d->hGrpSettings->SetBool("SavePythonHistory", saveh->isChecked());
}
}
void PythonConsole::onClearConsole()
{
clear();
d->output = d->info;
printPrompt(PythonConsole::Complete);
}
void PythonConsole::onSaveHistoryAs()
{
QString cMacroPath = QString::fromUtf8(
getDefaultParameter()
->GetGroup("Macro")
->GetASCII("MacroPath", App::Application::getUserMacroDir().c_str())
.c_str()
);
QString fn = FileDialog::getSaveFileName(
this,
tr("Save History"),
cMacroPath,
QStringLiteral("%1 (*.FCMacro *.py)").arg(tr("Macro Files"))
);
if (!fn.isEmpty()) {
int dot = fn.indexOf(QLatin1Char('.'));
if (dot != -1) {
QFile f(fn);
if (f.open(QIODevice::WriteOnly)) {
QTextStream t(&f);
const QStringList& hist = d->history.values();
for (const auto& it : hist) {
t << it << "\n";
}
f.close();
}
}
}
}
void PythonConsole::onInsertFileName()
{
QString fn = Gui::FileDialog::getOpenFileName(
Gui::getMainWindow(),
tr("Insert file name"),
QString(),
QStringLiteral("%1 (*.*)").arg(tr("All Files"))
);
if (!fn.isEmpty()) {
insertPlainText(fn);
}
}
/**
* Copy the history of the console into the clipboard.
*/
void PythonConsole::onCopyHistory()
{
if (d->history.isEmpty()) {
return;
}
d->type = PythonConsoleP::History;
QMimeData* data = createMimeDataFromSelection();
QApplication::clipboard()->setMimeData(data);
d->type = PythonConsoleP::Normal;
}
/**
* Copy the selected commands into the clipboard. This is a subset of the history.
*/
void PythonConsole::onCopyCommand()
{
d->type = PythonConsoleP::Command;
copy();
d->type = PythonConsoleP::Normal;
}
QString PythonConsole::readline()
{
QEventLoop loop;
// output is set to the current prompt which we need to extract
// the actual user input
QString inputBuffer = d->output;
printPrompt(PythonConsole::Special);
this->_sourceDrain = &inputBuffer; //< enable source drain ...
// ... and wait until we get notified about pendingSource
QObject::connect(this, &PythonConsole::pendingSource, &loop, &QEventLoop::quit);
// application is about to quit
if (loop.exec() != 0) {
PyErr_SetInterrupt();
} //< send SIGINT to python
this->_sourceDrain = nullptr; //< disable source drain
return inputBuffer.append(QChar::fromLatin1('\n')); //< pass a newline here, since the
// readline-caller may need it!
}
/**
* loads history contents from the default history file
*/
void PythonConsole::loadHistory() const
{
// only load contents if history is empty, to not overwrite anything
if (!d->history.isEmpty()) {
return;
}
if (!d->hGrpSettings->GetBool("SavePythonHistory", false)) {
return;
}
QFile f(d->historyFile);
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QString l;
while (!f.atEnd()) {
l = QString::fromUtf8(f.readLine());
if (!l.isEmpty()) {
l.chop(1); // removes the last \n
d->history.append(l);
}
}
f.close();
}
}
/**
* saves the current history to the default history file
*/
void PythonConsole::saveHistory() const
{
if (d->history.isEmpty()) {
return;
}
if (!d->hGrpSettings->GetBool("SavePythonHistory", false)) {
return;
}
QFile f(d->historyFile);
if (f.open(QIODevice::WriteOnly)) {
QTextStream t(&f);
QStringList hist = d->history.values();
// only save last 100 entries so we don't inflate forever...
if (hist.length() > 100) {
hist = hist.mid(hist.length() - 100);
}
for (const auto& it : hist) {
t << it << "\n";
}
f.close();
}
}
// ---------------------------------------------------------------------
PythonConsoleHighlighter::PythonConsoleHighlighter(QObject* parent)
: PythonSyntaxHighlighter(parent)
{}
PythonConsoleHighlighter::~PythonConsoleHighlighter() = default;
void PythonConsoleHighlighter::highlightBlock(const QString& text)
{
const int ErrorOutput = (int)PythonConsoleP::Error;
const int MessageOutput = (int)PythonConsoleP::Message;
// Get user state to re-highlight the blocks in the appropriate format
int stateOfPara = currentBlockState();
switch (stateOfPara) {
case ErrorOutput: {
// Error output
QTextCharFormat errorFormat;
errorFormat.setForeground(color(QLatin1String("Python error")));
errorFormat.setFontItalic(true);
setFormat(0, text.length(), errorFormat);
} break;
case MessageOutput: {
// Normal output
QTextCharFormat outputFormat;
outputFormat.setForeground(color(QLatin1String("Python output")));
setFormat(0, text.length(), outputFormat);
} break;
default: {
PythonSyntaxHighlighter::highlightBlock(text);
} break;
}
}
void PythonConsoleHighlighter::colorChanged(const QString& type, const QColor& col)
{
Q_UNUSED(type);
Q_UNUSED(col);
}
// ---------------------------------------------------------------------
ConsoleHistory::ConsoleHistory()
: _scratchBegin(0)
{
_it = _history.cend();
}
ConsoleHistory::~ConsoleHistory() = default;
void ConsoleHistory::first()
{
_it = _history.cbegin();
}
bool ConsoleHistory::more()
{
return (_it != _history.cend());
}
/**
* next switches the history pointer to the next item.
* While searching the next item, the routine respects the search prefix set by prev().
* @return true if the pointer was switched to a later item, false otherwise.
*/
bool ConsoleHistory::next()
{
bool wentNext = false;
// if we didn't reach history's end ...
if (_it != _history.cend()) {
// we go forward until we find an item matching the prefix.
for (++_it; _it != _history.cend(); ++_it) {
if (!_it->isEmpty() && _it->startsWith(_prefix)) {
break;
}
}
// we did a step - no matter of a matching prefix.
wentNext = true;
}
return wentNext;
}
/**
* prev switches the history pointer to the previous item.
* The optional parameter prefix allows one to search the history selectively for commands that
* start with a certain character sequence.
* @param prefix - prefix string for searching backwards in history, empty string by default
* @return true if the pointer was switched to an earlier item, false otherwise.
*/
bool ConsoleHistory::prev(const QString& prefix)
{
bool wentPrev = false;
// store prefix if it's the first history access
if (_it == _history.cend()) {
_prefix = prefix;
}
// while we didn't go back or reach history's begin ...
while (!wentPrev && _it != _history.cbegin()) {
// go back in history and check if item matches prefix
// Skip empty items
--_it;
wentPrev = (!_it->isEmpty() && _it->startsWith(_prefix));
}
return wentPrev;
}
bool ConsoleHistory::isEmpty() const
{
return _history.isEmpty();
}
const QString& ConsoleHistory::value() const
{
return (
(_it != _history.end()) ? *_it
/* else */
: _prefix
);
}
void ConsoleHistory::append(const QString& item)
{
_history.append(item);
// reset iterator to make the next history
// access begin with the latest item.
_it = _history.cend();
}
const QStringList& ConsoleHistory::values() const
{
return this->_history;
}
/**
* restart resets the history access to the latest item.
*/
void ConsoleHistory::restart()
{
_it = _history.cend();
}
/**
* markScratch stores the current end index of the history list.
* Note: with simply remembering a start index, it does not work to nest scratch regions.
* However, just replace the index keeping by a stack - in case this is be a concern.
*/
void ConsoleHistory::markScratch()
{
_scratchBegin = _history.length();
}
/**
* doScratch removes the tail of the history list, starting from the index marked lately.
*/
void ConsoleHistory::doScratch()
{
if (_scratchBegin < _history.length()) {
_history.erase(_history.begin() + _scratchBegin, _history.end());
this->restart();
}
}
// -----------------------------------------------------
#include "moc_PythonConsole.cpp"