From a7398525b2c48a2532dad10bec582a77024cb3d3 Mon Sep 17 00:00:00 2001 From: baldurk Date: Mon, 25 Dec 2017 13:21:05 +0000 Subject: [PATCH] Add simple tab-completion in interactive python shell --- qrenderdoc/Code/pyrenderdoc/PythonContext.cpp | 124 ++++++++++++++ qrenderdoc/Code/pyrenderdoc/PythonContext.h | 5 + qrenderdoc/Windows/PythonShell.cpp | 155 +++++++++++++++++- qrenderdoc/Windows/PythonShell.h | 3 + 4 files changed, 286 insertions(+), 1 deletion(-) diff --git a/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp b/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp index 7834aae8f..1631e08b9 100644 --- a/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp +++ b/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp @@ -242,6 +242,18 @@ void PythonContext::GlobalInit() // import sys PyDict_SetItemString(main_dict, "sys", PyImport_ImportModule("sys")); + PyObject *rlcompleter = PyImport_ImportModule("rlcompleter"); + + if(rlcompleter) + { + PyDict_SetItemString(main_dict, "rlcompleter", rlcompleter); + } + else + { + // ignore a failed import + PyErr_Clear(); + } + // sysobj = sys PyObject *sysobj = PyDict_GetItemString(main_dict, "sys"); @@ -329,6 +341,8 @@ PythonContext::PythonContext(QObject *parent) : QObject(parent) // clone our own local context context_namespace = PyDict_Copy(main_dict); + PyObject *rlcompleter = PyDict_GetItemString(main_dict, "rlcompleter"); + // for compatibility with earlier versions of python that took a char * instead of const char * char noparams[1] = ""; @@ -344,6 +358,40 @@ PythonContext::PythonContext(QObject *parent) : QObject(parent) Py_DECREF(redirector); } + if(rlcompleter) + { + PyObject *Completer = PyObject_GetAttrString(rlcompleter, "Completer"); + + if(Completer) + { + // create a completer for our context's namespace + m_Completer = PyObject_CallFunction(Completer, "O", context_namespace); + + if(m_Completer) + { + PyDict_SetItemString(context_namespace, "_renderdoc_completer", m_Completer); + } + else + { + QString typeStr; + QString valueStr; + int finalLine = -1; + QList frames; + FetchException(typeStr, valueStr, finalLine, frames); + + // failure is not fatal + qWarning() << "Couldn't create completion object. " << typeStr << ": " << valueStr; + PyErr_Clear(); + } + } + + Py_DecRef(Completer); + } + else + { + m_Completer = NULL; + } + // release the GIL again PyGILState_Release(gil); @@ -358,6 +406,11 @@ PythonContext::PythonContext(QObject *parent) : QObject(parent) PythonContext::~PythonContext() { + PyGILState_STATE gil = PyGILState_Ensure(); + if(m_Completer) + Py_DecRef(m_Completer); + PyGILState_Release(gil); + // do a final tick to gather any remaining output outputTick(); } @@ -577,6 +630,77 @@ QWidget *PythonContext::QWidgetFromPy(PyObject *widget) #endif } +QStringList PythonContext::completionOptions(QString base) +{ + QStringList ret; + + if(!m_Completer) + return ret; + + QByteArray bytes = base.toUtf8(); + const char *input = (const char *)bytes.data(); + + PyGILState_STATE gil = PyGILState_Ensure(); + + PyObject *completeFunction = PyObject_GetAttrString(m_Completer, "complete"); + + int idx = 0; + PyObject *opt = NULL; + do + { + opt = PyObject_CallFunction(completeFunction, "si", input, idx); + + if(opt && opt != Py_None) + { + QString optstr = ToQStr(opt); + + bool add = true; + + // little hack, remove some of the ugly swig template instantiations that we can't avoid. + if(optstr.contains(lit("renderdoc.rdcarray")) || optstr.contains(lit("renderdoc.rdcstr")) || + optstr.contains(lit("renderdoc.bytebuf"))) + add = false; + + if(add) + ret << optstr; + } + + idx++; + } while(opt && opt != Py_None); + + // extra hack, remove the swig object functions/data but ONLY if we find a sure-fire identifier + // (thisown) since otherwise we could remove append from a list object + bool containsSwigInternals = false; + for(const QString &optstr : ret) + { + if(optstr.contains(lit(".thisown"))) + { + containsSwigInternals = true; + break; + } + } + + if(containsSwigInternals) + { + for(int i = 0; i < ret.count();) + { + if(ret[i].endsWith(lit(".acquire(")) || ret[i].endsWith(lit(".append(")) || + ret[i].endsWith(lit(".disown(")) || ret[i].endsWith(lit(".next(")) || + ret[i].endsWith(lit(".own(")) || ret[i].endsWith(lit(".this")) || + ret[i].endsWith(lit(".thisown"))) + ret.removeAt(i); + else + i++; + } + } + + Py_DecRef(completeFunction); + + PyGILState_Release(gil); + + return ret; +} + PyObject *PythonContext::QtObjectToPython(const char *typeName, QObject *object) { #if PYSIDE2_ENABLED diff --git a/qrenderdoc/Code/pyrenderdoc/PythonContext.h b/qrenderdoc/Code/pyrenderdoc/PythonContext.h index 6001f5a58..6d9e4fccc 100644 --- a/qrenderdoc/Code/pyrenderdoc/PythonContext.h +++ b/qrenderdoc/Code/pyrenderdoc/PythonContext.h @@ -87,6 +87,8 @@ public: static PyObject *QWidgetToPy(QWidget *widget) { return QtObjectToPython("QWidget", widget); } static QWidget *QWidgetFromPy(PyObject *widget); + QStringList completionOptions(QString base); + void setThreadBlocking(bool block) { m_Block = block; } bool threadBlocking() { return m_Block; } void abort() { m_Abort = true; } @@ -116,6 +118,9 @@ private: // globals are set into and any scripts execute in PyObject *context_namespace = NULL; + // a rlcompleter.Completer object used for tab-completion + PyObject *m_Completer = NULL; + // this is set during an execute, so we can identify when a callback happens within our execute or // not PyThreadState *m_State = NULL; diff --git a/qrenderdoc/Windows/PythonShell.cpp b/qrenderdoc/Windows/PythonShell.cpp index def1e5d59..ac206d398 100644 --- a/qrenderdoc/Windows/PythonShell.cpp +++ b/qrenderdoc/Windows/PythonShell.cpp @@ -458,6 +458,8 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) scriptEditor->markerDefine(CURRENT_MARKER, SC_MARK_SHORTARROW); scriptEditor->markerDefine(CURRENT_MARKER + 1, SC_MARK_BACKGROUND); + scriptEditor->autoCSetMaxHeight(10); + ConfigureSyntax(scriptEditor, SCLEX_PYTHON); scriptEditor->setTabWidth(4); @@ -476,6 +478,20 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) } }); + QObject::connect(scriptEditor, &ScintillaEdit::charAdded, [this](int ch) { + if(ch == '.') + { + startAutocomplete(); + } + }); + + QObject::connect(scriptEditor, &ScintillaEdit::keyPressed, [this](QKeyEvent *ev) { + if(ev->key() == Qt::Key_Space && ev->modifiers() && Qt::ControlModifier) + { + startAutocomplete(); + } + }); + ui->scriptSplitter->insertWidget(0, scriptEditor); int w = ui->scriptSplitter->rect().width(); ui->scriptSplitter->setSizes({w * 2 / 3, w / 3}); @@ -693,6 +709,79 @@ void PythonShell::textOutput(bool isStdError, const QString &output) void PythonShell::interactive_keypress(QKeyEvent *event) { + if(event->key() == Qt::Key_Tab) + { + QString base = ui->lineInput->text(); + if(!base.isEmpty() && !base.rbegin()->isSpace()) + { + // search backwards from the end for the first non dotted identifier first, and extract that + // substring. This is just ASCII, not unicode + for(int i = base.count() - 1; i >= 0; i--) + { + if(!base[i].isLetterOrNumber() && base[i] != QLatin1Char('.') && base[i] != QLatin1Char('_')) + { + base = base.right(base.count() - 1 - i); + break; + } + } + + // skip any initial digits that got included in the coarse search above + while(!base.isEmpty() && base[0].isDigit()) + base.remove(0, 1); + + QStringList options = interactiveContext->completionOptions(base); + + QString line = ui->lineInput->text(); + + if(!options.isEmpty()) + { + QString commonSubstring = options[0]; + + for(int i = 1; i < options.count(); i++) + { + const QString &opt = options[i]; + if(opt.count() < commonSubstring.count()) + commonSubstring.truncate(opt.count()); + + for(int j = 0; j < commonSubstring.count(); j++) + { + if(commonSubstring[j] != opt[j]) + { + commonSubstring.truncate(j); + break; + } + } + } + + if(commonSubstring.length() > base.length()) + { + line.chop(base.length()); + line += commonSubstring; + ui->lineInput->setText(line); + } + + if(options.count() > 1) + { + QString text; + text += line; + text += lit("\n"); + for(const QString &opt : options) + { + text += opt; + text += lit("\n"); + } + text += m_storedLines.isEmpty() ? lit(">> ") : lit(".. "); + appendText(ui->interactiveOutput, text); + } + } + + return; + } + + ui->lineInput->insert(lit("\t")); + return; + } + if(event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) on_execute_clicked(); @@ -756,6 +845,65 @@ void PythonShell::enableButtons(bool enable) ui->abortRun->setEnabled(!enable); } +void PythonShell::startAutocomplete() +{ + sptr_t pos = scriptEditor->currentPos(); + sptr_t line = scriptEditor->lineFromPosition(pos); + sptr_t lineStart = scriptEditor->positionFromLine(line); + QByteArray lineText = scriptEditor->getLine(line); + + sptr_t end = pos - lineStart - 1; + sptr_t start; + for(start = end; start >= 0; start--) + { + if(QChar::fromLatin1(lineText[start]).isLetterOrNumber() || lineText[start] == '.' || + lineText[start] == '_') + continue; + + start++; + break; + } + + QString comp = QString::fromUtf8(lineText.mid(start, end - start + 1)); + + PythonContext *context = new PythonContext(); + + setGlobals(context); + + // super hack. Try to import any modules to get completion suggestions from them. + // we only process imports with no indentation since they should be unconditional. We ignore + // imports that fail. + QByteArray text = scriptEditor->getText(pos + 1); + + for(int offs = 0; offs < text.length();) + { + // find the next newline (may be NULL if we're at the end) + int newline = text.indexOf('\n', offs); + + // execute the import if there is one + const char *c = text.data() + offs; + if(!strncmp(c, "import ", 7)) + { + context->executeString(newline >= 0 ? QString::fromUtf8(c, newline - offs + 1) + : QString::fromUtf8(c)); + } + + if(newline < 0) + break; + + // move to the next line + offs = newline + 1; + } + + QStringList completions = context->completionOptions(comp); + + context->Finish(); + + qDebug() << "auto complete from" << comp; + + scriptEditor->autoCShow(comp.count(), completions.join(QLatin1Char(' ')).toUtf8().data()); +} + PythonContext *PythonShell::newContext() { PythonContext *ret = new PythonContext(); @@ -764,7 +912,12 @@ PythonContext *PythonShell::newContext() QObject::connect(ret, &PythonContext::exception, this, &PythonShell::exception); QObject::connect(ret, &PythonContext::textOutput, this, &PythonShell::textOutput); - ret->setGlobal("pyrenderdoc", (ICaptureContext *)m_ThreadCtx); + setGlobals(ret); return ret; } + +void PythonShell::setGlobals(PythonContext *ret) +{ + ret->setGlobal("pyrenderdoc", (ICaptureContext *)m_ThreadCtx); +} diff --git a/qrenderdoc/Windows/PythonShell.h b/qrenderdoc/Windows/PythonShell.h index e59674968..0f6854966 100644 --- a/qrenderdoc/Windows/PythonShell.h +++ b/qrenderdoc/Windows/PythonShell.h @@ -82,6 +82,9 @@ private: QString m_storedLines; PythonContext *newContext(); + void setGlobals(PythonContext *ret); + + void startAutocomplete(); QString scriptHeader(); void appendText(QTextEdit *output, const QString &text);