diff --git a/qrenderdoc/Code/Interface/PersistantConfig.h b/qrenderdoc/Code/Interface/PersistantConfig.h index f7124934a..c2ca7b1c3 100644 --- a/qrenderdoc/Code/Interface/PersistantConfig.h +++ b/qrenderdoc/Code/Interface/PersistantConfig.h @@ -394,6 +394,22 @@ DECLARE_REFLECTION_STRUCT(BugReport); "type: List[str]"); \ CONFIG_SETTING(public, QVariantList, rdcarray, Python_StubDirs) \ \ + DOCUMENT( \ + "``True`` if there should be a prompt when reloading script file changes from disk, even " \ + "if the script has not been changed in the editor. Otherwise changes are reloaded " \ + "silently.\n" \ + "\n" \ + "Defaults to ``False``." \ + "" \ + ":type: bool"); \ + CONFIG_SETTING_VAL(public, bool, bool, Python_PromptReloadUnchanged, false) \ + \ + DOCUMENT( \ + "List of files recently opened in the python scripting window.\n" \ + "\n:" \ + "type: List[str]"); \ + CONFIG_SETTING(public, QVariantList, rdcarray, Python_RecentFiles) \ + \ DOCUMENT( \ "``True`` if when loading a capture that was originally captured on a remote device but " \ "uses an API that can be supported locally, should be loaded locally without prompting to " \ diff --git a/qrenderdoc/Windows/Dialogs/SettingsDialog.cpp b/qrenderdoc/Windows/Dialogs/SettingsDialog.cpp index 59f901be4..53f77fab2 100644 --- a/qrenderdoc/Windows/Dialogs/SettingsDialog.cpp +++ b/qrenderdoc/Windows/Dialogs/SettingsDialog.cpp @@ -309,6 +309,7 @@ SettingsDialog::SettingsDialog(ICaptureContext &ctx, QWidget *parent) ui->Python_DebugEnabled->setChecked(m_Ctx.Config().Python_DebugEnabled); ui->Python_LaunchVSCode->setChecked(m_Ctx.Config().Python_LaunchVSCode); + ui->Python_PromptReloadUnchanged->setChecked(m_Ctx.Config().Python_PromptReloadUnchanged); ui->Python_DebugPyDir->setText(m_Ctx.Config().Python_DebugPyDir); @@ -886,6 +887,13 @@ void SettingsDialog::on_Python_LaunchVSCode_toggled(bool checked) m_Ctx.Config().Save(); } +void SettingsDialog::on_Python_PromptReloadUnchanged_toggled(bool checked) +{ + m_Ctx.Config().Python_PromptReloadUnchanged = ui->Python_PromptReloadUnchanged->isChecked(); + + m_Ctx.Config().Save(); +} + // texture viewer void SettingsDialog::on_TextureViewer_PerTexSettings_toggled(bool checked) { diff --git a/qrenderdoc/Windows/Dialogs/SettingsDialog.h b/qrenderdoc/Windows/Dialogs/SettingsDialog.h index 4036032a2..1d23fb592 100644 --- a/qrenderdoc/Windows/Dialogs/SettingsDialog.h +++ b/qrenderdoc/Windows/Dialogs/SettingsDialog.h @@ -87,6 +87,7 @@ private slots: void on_Python_DebugPyDir_textEdited(const QString &dir); void on_Python_DebugEnabled_toggled(bool checked); void on_Python_LaunchVSCode_toggled(bool checked); + void on_Python_PromptReloadUnchanged_toggled(bool checked); // texture viewer void on_TextureViewer_PerTexSettings_toggled(bool checked); diff --git a/qrenderdoc/Windows/Dialogs/SettingsDialog.ui b/qrenderdoc/Windows/Dialogs/SettingsDialog.ui index 8e3a1f600..aeab48043 100644 --- a/qrenderdoc/Windows/Dialogs/SettingsDialog.ui +++ b/qrenderdoc/Windows/Dialogs/SettingsDialog.ui @@ -770,7 +770,25 @@ If disabled, debugging will do nothing and wait for a debugger connection. - + + + + Prompt for reload unmodified scripts with changes from disk + + + + + + + If a script open in the python editor has changes on disk but has not been modified in the UI, +prompt when reloading from disk. + + + + + + + Qt::Vertical diff --git a/qrenderdoc/Windows/PythonShell.cpp b/qrenderdoc/Windows/PythonShell.cpp index f07ba48f0..4a725605b 100644 --- a/qrenderdoc/Windows/PythonShell.cpp +++ b/qrenderdoc/Windows/PythonShell.cpp @@ -25,6 +25,7 @@ #include "PythonShell.h" #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include "Code/QRDUtils.h" +#include "Code/Resources.h" #include "Code/ScintillaSyntax.h" #include "Code/pyrenderdoc/PythonContext.h" #include "Widgets/Extended/RDToolTip.h" @@ -1123,18 +1125,78 @@ PythonShell::PythonShell(ICaptureContext &ctx, QWidget *parent) on_clear_clicked(); on_newScript_clicked(); - ScintillaEdit *editor = m_Scintillas[0]; - ui->saveScript->setEnabled(false); - ToolWindowManagerArea *editorTabs = ui->docking->areaOf(editor); - QObject::connect(editorTabs, &QTabWidget::currentChanged, this, &PythonShell::editorTab_Changed); + setupTabs(); - ui->docking->addToolWindow(ui->replGroup, ToolWindowManager::AreaReference( - ToolWindowManager::BottomOf, editorTabs, 0.3f)); + ui->docking->addToolWindow( + ui->replGroup, ToolWindowManager::AreaReference(ToolWindowManager::BottomOf, + ui->docking->areaOf(m_Scintillas[0]), 0.3f)); ui->docking->setToolWindowProperties( ui->replGroup, ToolWindowManager::HideCloseButton | ToolWindowManager::DisallowFloatWindow); + ui->projectExplorer->setWindowTitle(tr("Project Explorer")); + ui->projectExplorer->setColumns({tr("Name")}); + ui->projectExplorer->hideGridLines(); + + m_Watcher = new QFileSystemWatcher({}, this); + + QObject::connect(m_Watcher, &QFileSystemWatcher::fileChanged, this, &PythonShell::openFileModified); + QObject::connect(m_Watcher, &QFileSystemWatcher::directoryChanged, this, + &PythonShell::updateExtensionProjects); + + ui->projectExplorer->beginUpdate(); + + m_Examples = new RDTreeWidgetItem({lit("Examples")}); + m_Examples->setSelectable(false); + m_Examples->setBold(true); + m_Examples->setIcon(0, Icons::help()); + + const QPair examples[] = { + {tr("Show a Buffer"), lit("Example will go here")}, + }; + + for(const QPair &example : examples) + { + RDTreeWidgetItem *ex = new RDTreeWidgetItem({example.first}); + + QFile file(example.second); + + file.open(QFile::ReadOnly); + ex->setData(0, Qt::UserRole, QString::fromUtf8(file.readAll())); + file.close(); + + m_Examples->addChild(ex); + } + + m_UIExtensions = new RDTreeWidgetItem({lit("UI Extensions")}); + m_UIExtensions->setSelectable(false); + m_UIExtensions->setBold(true); + m_UIExtensions->setIcon(0, Icons::plugin()); + + m_RecentFiles = new RDTreeWidgetItem({lit("Recent files")}); + m_RecentFiles->setSelectable(false); + m_RecentFiles->setBold(true); + m_RecentFiles->setIcon(0, Icons::page_white_edit()); + + ui->projectExplorer->addTopLevelItem(m_Examples); + ui->projectExplorer->addTopLevelItem(m_UIExtensions); + ui->projectExplorer->addTopLevelItem(m_RecentFiles); + + updateRecentFiles(false); + updateExtensionProjects(); + + ui->projectExplorer->endUpdate(); + + ui->projectExplorer->expandItem(m_RecentFiles); + + ui->docking->addToolWindow(ui->projectExplorer, ToolWindowManager::AreaReference( + ToolWindowManager::LeftOf, + ui->docking->areaOf(m_Scintillas[0]), 0.2f)); + ui->docking->setToolWindowProperties( + ui->projectExplorer, + ToolWindowManager::HideCloseButton | ToolWindowManager::DisallowFloatWindow); + ui->docking->addToolWindow(ui->outputGroup, ToolWindowManager::AreaReference(ToolWindowManager::AddTo, ui->docking->areaOf(ui->replGroup))); @@ -1219,6 +1281,9 @@ void PythonShell::doSyntaxCheck() if(!editor) return; + if(editor->lexer() != SCLEX_PYTHON) + return; + // don't syntax check while the user still seems to be editing, e.g. with autocomplete or a // function tooltip active. The syntax check timer will be restarted when these go away if(editor->autoCActive() || m_FuncTip) @@ -1249,6 +1314,151 @@ void PythonShell::editorTab_Changed(int index) ui->saveScript->setEnabled(getEditorFilename(editor) != QString()); } +void PythonShell::openFileModified(const QString &path) +{ + for(ScintillaEdit *edit : m_Scintillas) + { + if(getEditorFilename(edit) == path) + { + // delay slightly to avoid reading while the file is being written or if it was deleted before + // being written as some editors do + QTimer::singleShot(150, [this, edit, path]() { + bool mod = isEditorModified(edit); + + // re-add the path, it may have been removed if the file was deleted + m_Watcher->addPath(path); + + if(!mod && !m_Ctx.Config().Python_PromptReloadUnchanged) + { + // unconditionally reload + } + else + { + QString prompt = tr("Reload from disk and overwrite your changes?"); + if(!mod) + prompt = tr("Reload from disk?"); + + QMessageBox::StandardButton response = RDDialog::question( + this, tr("%1 has been modified on disk").arg(QFileInfo(path).fileName()), + tr("%1 has been modified on disk. %2").arg(QFileInfo(path).fileName()).arg(prompt)); + + if(response == QMessageBox::No) + return; + } + + QFile f(path); + if(f.open(QIODevice::ReadOnly | QIODevice::Text)) + { + edit->setText(f.readAll().data()); + markEditorModified(edit, false); + return; + } + + RDDialog::critical(this, tr("Error reloading script"), + tr("Couldn't open %1.\n%2").arg(path).arg(f.errorString())); + }); + } + } +} + +void PythonShell::editorTab_Menu(const QPoint &pos) +{ + ToolWindowManagerArea *editorTabs = ui->docking->areaOf(m_Scintillas[0]); + + int tabIndex = editorTabs->tabBar()->tabAt(pos); + + if(tabIndex == -1) + return; + + if(editorTabs->count() == 1) + return; + + QAction closeTab(tr("Close tab"), this); + QAction closeOtherTabs(tr("Close other tabs"), this); + QAction closeRightTabs(tr("Close tabs to the right"), this); + + QMenu contextMenu(this); + + contextMenu.addAction(&closeTab); + contextMenu.addAction(&closeOtherTabs); + contextMenu.addAction(&closeRightTabs); + + QObject::connect(&closeTab, &QAction::triggered, [this, editorTabs, tabIndex]() { + // remove the tab at this index + delete editorTabs->widget(tabIndex); + }); + + QObject::connect(&closeRightTabs, &QAction::triggered, [this, editorTabs, tabIndex]() { + for(int i = editorTabs->count() - 1; i > tabIndex; i--) + delete editorTabs->widget(i); + }); + + QObject::connect(&closeOtherTabs, &QAction::triggered, [this, editorTabs, tabIndex]() { + for(int i = editorTabs->count() - 1; i >= 0; i--) + if(i != tabIndex) + delete editorTabs->widget(i); + }); + + RDDialog::show(&contextMenu, QCursor::pos()); +} + +void PythonShell::updateExtensionProjects() +{ + RDTreeViewExpansionState expansion; + ui->projectExplorer->saveExpansion(expansion, 0); + + ui->projectExplorer->beginUpdate(); + + m_UIExtensions->clear(); + + for(const ExtensionMetadata &ext : m_Ctx.Extensions().GetInstalledExtensions()) + { + RDTreeWidgetItem *root = new RDTreeWidgetItem({ext.name}); + + addExtensionDirItems(root, QDir(ext.filePath)); + + m_UIExtensions->addChild(root); + } + + ui->projectExplorer->endUpdate(); + + ui->projectExplorer->applyExpansion(expansion, 0); +} + +void PythonShell::addExtensionDirItems(RDTreeWidgetItem *root, QDir dir) +{ + m_Watcher->addPath(dir.absolutePath()); + for(QString child : dir.entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) + { + QString path = dir.absoluteFilePath(child); + QFileInfo fileInfo(path); + + // only show .py files, .md files (for README.md) and the extension.json + if(fileInfo.isFile() && fileInfo.suffix().toLower() != lit("py") && + fileInfo.suffix().toLower() != lit("md") && child.toLower() != lit("extension.json")) + continue; + + RDTreeWidgetItem *item = new RDTreeWidgetItem({child}); + if(fileInfo.isDir()) + { + // ignore some directories + if(child.toLower() == lit("__pycache__") || child.toLower() == lit(".git") || + child.toLower() == lit(".vscode")) + continue; + + item->setIcon(0, Icons::folder()); + + addExtensionDirItems(item, QDir(path)); + } + else + { + item->setData(0, Qt::UserRole, path); + } + + root->addChild(item); + } +} + ScintillaEdit *PythonShell::curEditor() { for(ScintillaEdit *edit : m_Scintillas) @@ -1260,7 +1470,7 @@ ScintillaEdit *PythonShell::curEditor() return NULL; } -ScintillaEdit *PythonShell::makeEditor() +ScintillaEdit *PythonShell::makeEditor(rdcstr filename) { ScintillaEdit *editor = new EditorWrapper(this); @@ -1288,7 +1498,24 @@ ScintillaEdit *PythonShell::makeEditor() QObject::connect(editor, &ScintillaEdit::customContextMenuRequested, this, &PythonShell::editor_contextMenu); - ConfigureSyntax(editor, SCLEX_PYTHON); + QString suffix; + + if(!filename.isEmpty()) + suffix = QFileInfo(filename).suffix().toLower(); + if(suffix == lit("md")) + { + ConfigureSyntax(editor, SCLEX_NULL); + editor->setWrapMode(SC_WRAP_WORD); + } + else if(suffix.toLower() == lit("json")) + { + ConfigureSyntax(editor, SCLEX_JSON); + editor->setWrapMode(SC_WRAP_WORD); + } + else + { + ConfigureSyntax(editor, SCLEX_PYTHON); + } editor->setTabWidth(4); editor->setUseTabs(false); @@ -1307,6 +1534,7 @@ ScintillaEdit *PythonShell::makeEditor() QObject::connect(editor, &QWidget::destroyed, [this, editor]() { m_Scintillas.removeOne(editor); + m_Watcher->removePath(getEditorFilename(editor)); updateEditorCloseButton(); }); @@ -1458,6 +1686,58 @@ void PythonShell::updateEditorCloseButton() } } +void PythonShell::addRecentFile(rdcstr filename) +{ + // don't add recent files from UI extensions + for(const ExtensionMetadata &ext : m_Ctx.Extensions().GetInstalledExtensions()) + { + if(QString(QFileInfo(filename).absolutePath()) + .toLower() + .startsWith(QDir(ext.filePath).absolutePath().toLower())) + { + return; + } + } + + m_Ctx.Config().Python_RecentFiles.removeIf([filename](const rdcstr &o) { return o == filename; }); + + if(m_Ctx.Config().Python_RecentFiles.size() == 10) + m_Ctx.Config().Python_RecentFiles.pop_back(); + + m_Ctx.Config().Python_RecentFiles.insert(0, filename); + + m_Ctx.Config().Save(); + + updateRecentFiles(true); +} + +void PythonShell::updateRecentFiles(bool added) +{ + ui->projectExplorer->beginUpdate(); + + bool expanded = ui->projectExplorer->isItemExpanded(m_RecentFiles); + + // expand if we're adding the first recent file + if(m_Ctx.Config().Python_RecentFiles.size() == 1 && added) + expanded = true; + + m_RecentFiles->clear(); + + for(rdcstr file : m_Ctx.Config().Python_RecentFiles) + { + RDTreeWidgetItem *recent = new RDTreeWidgetItem({QFileInfo(file).fileName()}); + recent->setData(0, Qt::UserRole, QString(file)); + m_RecentFiles->addChild(recent); + } + + ui->projectExplorer->endUpdate(); + + if(expanded) + ui->projectExplorer->expandItem(m_RecentFiles); + else + ui->projectExplorer->collapseItem(m_RecentFiles); +} + bool PythonShell::eventFilter(QObject *watched, QEvent *event) { if(qobject_cast(watched)) @@ -1492,6 +1772,14 @@ QVariant PythonShell::persistData() { QVariantMap state = ui->docking->saveState(); + RDTreeViewExpansionState expansion; + ui->projectExplorer->saveExpansion(expansion, 0); + + QVariantList expansionList; + for(uint x : expansion) + expansionList.append(x); + state[lit("projectExplorerExpansion")] = expansionList; + return state; } @@ -1501,8 +1789,25 @@ void PythonShell::setPersistData(const QVariant &persistData) ui->docking->restoreState(state); + ui->projectExplorer->collapseAll(); + + RDTreeViewExpansionState expansion; + for(QVariant x : state[lit("projectExplorerExpansion")].toList()) + expansion.insert(x.toUInt()); + ui->projectExplorer->applyExpansion(expansion, 0); + + setupTabs(); +} + +void PythonShell::setupTabs() +{ ToolWindowManagerArea *editorTabs = ui->docking->areaOf(m_Scintillas[0]); QObject::connect(editorTabs, &QTabWidget::currentChanged, this, &PythonShell::editorTab_Changed); + + editorTabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu); + + QObject::connect(editorTabs->tabBar(), &QTabBar::customContextMenuRequested, this, + &PythonShell::editorTab_Menu); } PythonContext *PythonShell::GetScriptContext() @@ -1522,13 +1827,17 @@ bool PythonShell::LoadScriptFromFilename(rdcstr filename) QFile f(filename); if(f.open(QIODevice::ReadOnly | QIODevice::Text)) { - ScintillaEdit *editor = makeEditor(); + ScintillaEdit *editor = makeEditor(filename); editor->setText(f.readAll().data()); editor->emptyUndoBuffer(); ui->saveScript->setEnabled(true); + addRecentFile(filename); + + m_Watcher->addPath(filename); + setEditorFilename(editor, QString(filename)); markEditorModified(editor, false); return true; @@ -1540,7 +1849,7 @@ bool PythonShell::LoadScriptFromFilename(rdcstr filename) void PythonShell::CreateNewScriptEditor(rdcstr name, rdcstr text) { - ScintillaEdit *editor = makeEditor(); + ScintillaEdit *editor = makeEditor(""); editor->setText(text.c_str()); editor->emptyUndoBuffer(); @@ -1705,7 +2014,7 @@ void PythonShell::on_newScript_clicked() minidocHeader = QFormatStr("# %1\n\n").arg(minidocHeader); - ScintillaEdit *editor = makeEditor(); + ScintillaEdit *editor = makeEditor(""); ui->saveScript->setEnabled(false); @@ -1826,6 +2135,69 @@ void PythonShell::on_outputContext_currentIndexChanged(int idx) updateScriptOutput(true); } +void PythonShell::on_projectExplorer_itemActivated(RDTreeWidgetItem *item, int column) +{ + // ignore these, just let them collapse/expand + if(item == m_Examples || item == m_UIExtensions || item == m_RecentFiles) + return; + + if(item->parent() == m_Examples) + { + QString filename = tr("Example: ") + item->text(0); + QString text = item->data(0, Qt::UserRole).toString(); + + for(ScintillaEdit *edit : m_Scintillas) + { + if(getEditorFilename(edit) == filename) + { + ToolWindowManager::raiseToolWindow(edit); + return; + } + } + + CreateNewScriptEditor(filename, text); + } + else + { + // recent file or UI extension, the user role contains the path + QString filename = item->data(0, Qt::UserRole).toString(); + + // directories in UI extensions have no filename to activate + if(filename.isEmpty()) + return; + + for(ScintillaEdit *edit : m_Scintillas) + { + if(getEditorFilename(edit) == filename) + { + ToolWindowManager::raiseToolWindow(edit); + return; + } + } + + if(!QFileInfo(filename).exists()) + { + QMessageBox::StandardButton response = RDDialog::question( + this, tr("'%1' does not exist").arg(QFileInfo(filename).fileName()), + tr("File not found at path:\n%1\n\nRemove from recent files list?").arg(filename)); + + if(response == QMessageBox::Yes) + { + rdcstr f = filename; + m_Ctx.Config().Python_RecentFiles.removeIf([f](const rdcstr &o) { return o == f; }); + + m_Ctx.Config().Save(); + + GUIInvoke::defer(this, [this]() { updateRecentFiles(false); }); + } + + return; + } + + LoadScriptFromFilename(filename); + } +} + bool PythonShell::checkAllowClose() { for(ScintillaEdit *edit : m_Scintillas) @@ -1881,6 +2253,7 @@ bool PythonShell::saveEditorAs(ScintillaEdit *editor) bool PythonShell::saveEditor(ScintillaEdit *editor, QString filename) { + QString oldFilename = getEditorFilename(editor); if(!filename.isEmpty()) { QDir dirinfo = QFileInfo(filename).dir(); @@ -1889,10 +2262,20 @@ bool PythonShell::saveEditor(ScintillaEdit *editor, QString filename) QFile f(filename); if(f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + // remove the path from watching first so we don't get a notification from the write itself + m_Watcher->removePath(oldFilename); + QString text = QString::fromUtf8(editor->getText(editor->textLength() + 1)); text.remove(QLatin1Char('\r')); f.write(text.toUtf8()); + addRecentFile(filename); + + // delay a short while before starting to watch this file. This is highly unlikely to miss + // any real writes (which would have to happen externally after we save), but prevents us + // from identifying our own writes as an external modification. + QTimer::singleShot(200, [this, filename]() { m_Watcher->addPath(filename); }); + setEditorFilename(editor, filename); return true; } diff --git a/qrenderdoc/Windows/PythonShell.h b/qrenderdoc/Windows/PythonShell.h index 149ca5765..cf486239e 100644 --- a/qrenderdoc/Windows/PythonShell.h +++ b/qrenderdoc/Windows/PythonShell.h @@ -24,6 +24,7 @@ #pragma once +#include #include #include "Code/Interface/QRDInterface.h" #include "scintilla/include/qt/ScintillaEdit.h" @@ -34,6 +35,8 @@ class RDToolTip; class QTimer; class QCompleter; class QStringListModel; +class QFileSystemWatcher; +class RDTreeWidgetItem; namespace Ui { @@ -107,6 +110,8 @@ private slots: void on_abortRun_clicked(); void on_outputContext_currentIndexChanged(int idx); + void on_projectExplorer_itemActivated(RDTreeWidgetItem *item, int column); + bool checkAllowClose(); // manual slots @@ -121,6 +126,10 @@ private slots: void editorTab_Changed(int index); void doSyntaxCheck(); + void openFileModified(const QString &path); + void updateExtensionProjects(); + void editorTab_Menu(const QPoint &pos); + private: Ui::PythonShell *ui; ICaptureContext &m_Ctx; @@ -134,6 +143,10 @@ private: bool m_ContextMenuVisible = false; bool m_HelpPrinting = false; + RDTreeWidgetItem *m_UIExtensions, *m_Examples, *m_RecentFiles; + + QFileSystemWatcher *m_Watcher = NULL; + QTimer *m_SyntaxCheckTimer; QCompleter *m_InteractiveCompleter; @@ -161,10 +174,17 @@ private: QList m_Scintillas; + void setupTabs(); + ScintillaEdit *curEditor(); - ScintillaEdit *makeEditor(); + ScintillaEdit *makeEditor(rdcstr filename); void updateEditorCloseButton(); + void addRecentFile(rdcstr filename); + void updateRecentFiles(bool added); + + void addExtensionDirItems(RDTreeWidgetItem *root, QDir dir); + bool eventFilter(QObject *watched, QEvent *event) override; void updateScriptOutput(bool fullRefresh); diff --git a/qrenderdoc/Windows/PythonShell.ui b/qrenderdoc/Windows/PythonShell.ui index 6609d870c..66a336a76 100644 --- a/qrenderdoc/Windows/PythonShell.ui +++ b/qrenderdoc/Windows/PythonShell.ui @@ -436,6 +436,19 @@ + + + + 540 + 40 + 161 + 141 + + + + QFrame::NoFrame + + @@ -453,6 +466,11 @@ QTextEdit
Widgets/Extended/RDTextEdit.h
+ + RDTreeWidget + QTreeWidget +
Widgets/Extended/RDTreeWidget.h
+