From b3710eba3b8a9b6e6f9ed744ce910a7507f3d48c Mon Sep 17 00:00:00 2001 From: baldurk Date: Thu, 6 Aug 2026 11:28:47 +0100 Subject: [PATCH] Fetch & display tooltips for each entry along with autocomplete options --- qrenderdoc/Code/pyrenderdoc/PythonContext.cpp | 12 +- qrenderdoc/Code/pyrenderdoc/PythonContext.h | 2 +- .../Code/pyrenderdoc/parse_reflection.py | 64 ++++++--- qrenderdoc/Windows/PythonShell.cpp | 121 +++++++++++++++++- qrenderdoc/Windows/PythonShell.h | 8 +- 5 files changed, 177 insertions(+), 30 deletions(-) diff --git a/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp b/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp index 67bd5e927..616ab29cb 100644 --- a/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp +++ b/qrenderdoc/Code/pyrenderdoc/PythonContext.cpp @@ -1841,9 +1841,10 @@ QString PythonContext::typenameForLoc(int line, int col) return ret; } -QStringList PythonContext::completionOptions(int line, QString expr, int &prefix_len) +QList> PythonContext::completionOptions(int line, QString expr, + int &prefix_len) { - QStringList ret; + QList> ret; PyGILState_STATE gil = PyGILState_Ensure(); @@ -1864,12 +1865,17 @@ QStringList PythonContext::completionOptions(int line, QString expr, int &prefix { PyObject *comp_list = PyTuple_GetItem(completions, 0); prefix_len = PyLong_AsLong(PyTuple_GetItem(completions, 1)); + PyObject *tip_list = PyTuple_GetItem(completions, 2); if(comp_list) { for(Py_ssize_t i = 0, len = PyList_Size(comp_list); i < len; i++) { - ret << ToQStr(PyList_GetItem(comp_list, i)); + QPair item; + item.first = ToQStr(PyList_GetItem(comp_list, i)); + if(tip_list) + item.second = ToQStr(PyList_GetItem(tip_list, i)); + ret << item; } } } diff --git a/qrenderdoc/Code/pyrenderdoc/PythonContext.h b/qrenderdoc/Code/pyrenderdoc/PythonContext.h index 8def25dba..a01f3690a 100644 --- a/qrenderdoc/Code/pyrenderdoc/PythonContext.h +++ b/qrenderdoc/Code/pyrenderdoc/PythonContext.h @@ -118,7 +118,7 @@ public: void reflectSource(QString src); QString tooltipForLoc(int line, int col); - QStringList completionOptions(int line, QString expr, int &prefix_len); + QList> completionOptions(int line, QString expr, int &prefix_len); QString tryFunctionCompletion(int line, QString expr); QString typenameForLoc(int line, int col); diff --git a/qrenderdoc/Code/pyrenderdoc/parse_reflection.py b/qrenderdoc/Code/pyrenderdoc/parse_reflection.py index 0d68fdc1a..5bf95dc68 100644 --- a/qrenderdoc/Code/pyrenderdoc/parse_reflection.py +++ b/qrenderdoc/Code/pyrenderdoc/parse_reflection.py @@ -1809,18 +1809,19 @@ class PyReflector: try: expr = self._get_atom_expr(self.module, line, col) - if expr is not None: - loctype = self._get_type(self.scopes[line], expr) - else: + if expr is None: return "" except: return "" - if loctype is Any: - return "" + return self._get_tooltip_for_node(expr, line) + + def _get_tooltip_for_node(self, expr: ast.AST, line: int) -> str: + loctype = self._get_type(self.scopes[line], expr) if ( - callable(loctype) + loctype is not Any + and callable(loctype) and not inspect.isclass(loctype) and not _is_generic(List, loctype) and not _is_generic(Tuple, loctype) @@ -1848,7 +1849,10 @@ class PyReflector: else: ret = "expression: " - ret += self.get_name(loctype) + if loctype is Any: + ret += "Unknown Type" + else: + ret += self.get_name(loctype) if docappend != "": ret += "\n\n" @@ -1860,6 +1864,9 @@ class PyReflector: ret = "" if _is_generic(Callable, functype): + if not hasattr(functype, "__args__"): + return "Callable()" + args = functype.__args__ retType = args[-1] @@ -1983,11 +1990,13 @@ class PyReflector: ret = ret.replace(" ", "  ") return ret.strip() - def get_autocompletion(self, line: int, expr: str) -> Tuple[List[str], int]: + def get_autocompletion( + self, line: int, expr: str + ) -> Tuple[List[str], int, List[str]]: expr = _get_trailing_expr(expr).strip() if expr == "": - return [], 0 + return [], 0, [] trailing_dot = expr[-1] == "." if trailing_dot: @@ -1999,10 +2008,10 @@ class PyReflector: try: node = ast.parse(src) if not isinstance(node, ast.Module): - return [], 0 + return [], 0, [] node = node.body[0] if not isinstance(node, ast.Expr): - return [], 0 + return [], 0, [] node = node.value curscope = self.scopes[min(len(self.scopes) - 1, line)] @@ -2010,32 +2019,43 @@ class PyReflector: ret: List[str] = [] prefix_filter = "" + def get_member_tooltip(member_name: str): + if isinstance(node, ast.Attribute): + node.attr = member_name + return self._get_tooltip_for_node(node, line) + elif isinstance(node, ast.Name): + node.id = member_name + return self._get_tooltip_for_node(node, line) + else: + raise RuntimeError("Unexpected node type getting member tooltip") + # for just a name, filter the identifiers at this point if there was no trailing dot if isinstance(node, ast.Name) and not trailing_dot: - idents = [] + prefix_filter = node.id while curscope is not None: for k, v in curscope.identifiers.items(): if any([x.line <= line for x in v]): - idents.append(k) + ret.append(k) curscope = curscope.parent - ret = idents - prefix_filter = node.id else: # we provide completion for attribute access, which can either look like just a # name (if there was a trailing dot so we didn't hit the case above) base_type = Any prefix_filter = "" + if isinstance(node, ast.Name) or trailing_dot: base_type = self._get_type(curscope, node) + + node = ast.Attribute(node, "", lineno=node.lineno) elif isinstance(node, ast.Attribute): base_type = self._get_type(curscope, node.value) prefix_filter = node.attr else: - return [], 0 + return [], 0, [] # if the base type is unknown in some fashion, nothing to do if base_type is Any or base_type is None: - return [], 0 + return [], 0, [] # if this is a user type, look up its identifiers from our list if isinstance(base_type, TypeVar): @@ -2044,7 +2064,7 @@ class PyReflector: base_scope = self.scopes[base_ident.line] ret = list(base_scope.identifiers.keys()) else: - return [], 0 + return [], 0, [] else: # pre-python 3.8 Dict, List etc are not the real types, substitute here if sys.version_info < (3, 9): @@ -2072,11 +2092,11 @@ class PyReflector: # sort alphabetically, case-insensitively ret = sorted(ret, key=lambda x: x.upper()) - return ret, len(prefix_filter) + return ret, len(prefix_filter), [get_member_tooltip(x) for x in ret] except: pass - return [], 0 + return [], 0, [] def get_funccompletion(self, line: int, expr: str) -> Tuple[str, str, str]: func, argidx = _get_func_arg(_get_trailing_expr(expr + ")")) @@ -2151,6 +2171,8 @@ class PyReflector: ] for g, n in generics: if _is_generic(g, obj): + if not hasattr(obj, "__args__"): + return n args = ", ".join([self.get_name(a) for a in obj.__args__]) return f"{n}[{args}]" @@ -2410,7 +2432,7 @@ if __name__ == "__main__" and sys.version_info >= (3, 8): if "# AUTOCOMPLETE TEST" in line_text and not "#exclude" in line_text: line = i + 1 - completions, prefix_len = refl.get_autocompletion(line, entry) + completions, prefix_len, tooltips = refl.get_autocompletion(line, entry) if expected_prefix_len != prefix_len: raise RuntimeError( diff --git a/qrenderdoc/Windows/PythonShell.cpp b/qrenderdoc/Windows/PythonShell.cpp index db5a6c5e6..86d32d36d 100644 --- a/qrenderdoc/Windows/PythonShell.cpp +++ b/qrenderdoc/Windows/PythonShell.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -222,6 +223,32 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) m_SyntaxCheckTimer->setSingleShot(true); m_SyntaxCheckTimer->setInterval(1200); + m_CompletionTipTimer = new QTimer(this); + m_CompletionTipTimer->setSingleShot(false); + // this timer will only be active while auto completing so we can be aggressive with its timer + m_CompletionTipTimer->setInterval(10); + QObject::connect(m_CompletionTipTimer, &QTimer::timeout, [this]() { + EditorWrapper *editor = curEditor(); + + if(!editor || !editor->scintilla()->autoCActive()) + { + m_CompletionTipTimer->stop(); + m_CompletionTipList.clear(); + m_CurrentCompletionTip = -1; + + updateCompletionTip(); + } + else + { + if(editor->scintilla()->autoCCurrent() != m_CurrentCompletionTip) + { + m_CurrentCompletionTip = editor->scintilla()->autoCCurrent(); + + updateCompletionTip(); + } + } + }); + // only update the current line intermittently. We don't need to update every single time and if // there is a large number of traces this will rate limit it. m_CurLineTimer = new QTimer(this); @@ -271,6 +298,9 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) m_ToolTip->setFont(Formatter::FixedFont()); + m_CompletionTip = new RDToolTip(this); + m_CompletionTip->setFont(Formatter::FixedFont()); + m_InteractiveCompleter = new QCompleter(this); m_InteractiveCompleter->popup()->setFont(Formatter::FixedFont()); m_InteractiveCompleter->popup()->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); @@ -282,6 +312,21 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) m_InteractiveCompleter->setModel(m_InteractiveCompletionModel); m_InteractiveCompleter->setCompletionRole(Qt::DisplayRole); + m_InteractiveCompleter->popup()->installEventFilter(this); + + QObject::connect(m_InteractiveCompleter, + OverloadedSlot::of(&QCompleter::highlighted), + [this](const QModelIndex &idx) { + if(idx.isValid() && idx.row() < m_CompletionTipList.count()) + { + if(m_CurrentCompletionTip != idx.row()) + { + m_CurrentCompletionTip = idx.row(); + updateCompletionTip(); + } + } + }); + QObject::connect(m_InteractiveCompleter, OverloadedSlot::of(&QCompleter::activated), [this](const QModelIndex &idx) { @@ -1084,6 +1129,13 @@ bool PythonShell::eventFilter(QObject *watched, QEvent *event) } } + if(m_InteractiveCompleter && watched == m_InteractiveCompleter->popup() && + (event->type() == QEvent::Hide || event->type() == QEvent::FocusOut)) + { + m_CurrentCompletionTip = -1; + updateCompletionTip(); + } + if(m_FuncTip && watched == m_FuncTipWidget && event->type() == QEvent::FocusOut) { hideFunccompleteTooltip(); @@ -2185,9 +2237,20 @@ void PythonShell::interactive_keypress(QKeyEvent *event) QString base = ui->lineInput->text(); QStringList completions; + int oldCount = m_CompletionTipList.count(); + m_CompletionTipList.clear(); if(base.trimmed() != QString()) - completions = interactiveContext->completionOptions(0, base, m_InteractiveCompletionPrefix); + { + m_CompletionTipList = + interactiveContext->completionOptions(0, base, m_InteractiveCompletionPrefix); + + for(const QPair &item : m_CompletionTipList) + completions << item.first; + } + + if(oldCount != m_CompletionTipList.count()) + m_CurrentCompletionTip = -1; if(completions.isEmpty()) { @@ -2241,6 +2304,8 @@ void PythonShell::interactive_keypress(QKeyEvent *event) ui->lineInput->style()->pixelMetric(QStyle::PM_ButtonMargin)); m_InteractiveCompleter->complete(r); + m_InteractiveCompleter->popup()->selectionModel()->setCurrentIndex( + m_InteractiveCompletionModel->index(0), QItemSelectionModel::ClearAndSelect); return; } @@ -2355,18 +2420,33 @@ void PythonShell::doAutocomplete(ScintillaEdit *editor) QByteArray lineText = editor->getLine(line); lineText.resize(pos - lineStart); + int oldCount = m_CompletionTipList.count(); + int prefix_len = 0; - QStringList completions = + m_CompletionTipList = completionContext->completionOptions(line, QString::fromUtf8(lineText), prefix_len); - if(completions.empty()) + if(m_CompletionTipList.empty()) { doFunccomplete(editor); return; } + QString completion_merged; + for(const QPair &item : m_CompletionTipList) + { + completion_merged += item.first; + completion_merged += QLatin1Char(' '); + } + completion_merged.remove(completion_merged.count() - 1, 1); + + if(oldCount != m_CompletionTipList.count()) + m_CurrentCompletionTip = -1; + hideFunccompleteTooltip(); - editor->autoCShow(prefix_len, completions.join(QLatin1Char(' ')).toUtf8().data()); + editor->autoCShow(prefix_len, completion_merged.toUtf8().data()); + // scintilla doesn't give us a callback/event when an item is highlighted, so we query in a timer + m_CompletionTipTimer->start(); } void PythonShell::doFunccomplete(ScintillaEdit *editor) @@ -2409,6 +2489,39 @@ void PythonShell::hideFunccompleteTooltip() m_SyntaxCheckTimer->start(); } +void PythonShell::updateCompletionTip() +{ + if(m_CompletionTipList.empty() || m_CurrentCompletionTip < 0 || + m_CurrentCompletionTip >= m_CompletionTipList.count()) + { + m_CurrentCompletionTip = -1; + m_CompletionTip->hideTip(); + return; + } + + m_CompletionTip->configureTip(this, m_CompletionTipList[m_CurrentCompletionTip].second); + { + QPoint pos; + if(m_InteractiveCompleter->popup()->isVisible()) + { + pos = m_InteractiveCompleter->popup()->mapToGlobal( + m_InteractiveCompleter->popup()->rect().topRight()); + } + else + { + EditorWrapper *editor = curEditor(); + + if(editor) + { + pos = QPoint(editor->scintilla()->autoCRectRight(), editor->scintilla()->autoCRectTop()); + } + } + + if(pos != QPoint()) + m_CompletionTip->showTipAtPos(pos); + } +} + PythonContext *PythonShell::newContext() { PythonContext *ret = new PythonContext(); diff --git a/qrenderdoc/Windows/PythonShell.h b/qrenderdoc/Windows/PythonShell.h index e28482758..3de4d5723 100644 --- a/qrenderdoc/Windows/PythonShell.h +++ b/qrenderdoc/Windows/PythonShell.h @@ -186,10 +186,15 @@ private: QTimer *m_SyntaxCheckTimer; - QCompleter *m_InteractiveCompleter; + QCompleter *m_InteractiveCompleter = NULL; QStringListModel *m_InteractiveCompletionModel; int m_InteractiveCompletionPrefix = 0; + RDToolTip *m_CompletionTip; + QList> m_CompletionTipList; + sptr_t m_CurrentCompletionTip = -1; + QTimer *m_CompletionTipTimer; + static const int CURRENT_MARKER = 0; static const int STYLE_ERROR = 100; @@ -245,6 +250,7 @@ private: void doFunccomplete(ScintillaEdit *editor); void hideFunccompleteTooltip(); + void updateCompletionTip(); void selectedHelp(QString word); void refreshCurrentHelp();