[education/cantor] src: Improve the worksheet structure navigator

Alexander Semke <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit fccedb016b0e3470d9ea7f280463252612a33531 by Alexander Semke, on behalf of Nanhao Lv.
Committed on 26/07/2026 at 19:50.
Pushed by asemke into branch 'master'.

Improve the worksheet structure navigator

M  +2    -6    src/backends/R/rexpression.cpp
M  +1    -3    src/backends/julia/juliaexpression.cpp
M  +0    -1    src/backends/maxima/maximaexpression.cpp
M  +0    -1    src/backends/octave/octaveexpression.cpp
M  +0    -2    src/backends/python/pythonexpression.cpp
M  +1    -3    src/backends/qalculate/qalculateexpression.cpp
M  +2    -6    src/backends/sage/sageexpression.cpp
M  +1    -3    src/backends/scilab/scilabexpression.cpp
M  +9    -6    src/cantor.cpp
M  +22   -10   src/commandentry.cpp
M  +0    -1    src/commandentry.h
M  +8    -1    src/lib/panelpluginhandler.cpp
M  +0    -37   src/lib/result.cpp
M  +0    -14   src/lib/result.h
M  +256  -12   src/panelplugins/tocpanel/tocpanelplugin.cpp
M  +17   -1    src/panelplugins/tocpanel/tocpanelplugin.h
M  +13   -0    src/panelplugins/variablemgr/variablemanagerwidget.cpp
M  +74   -1    src/worksheethierarchymanager.cpp

https://invent.kde.org/education/cantor/-/commit/fccedb016b0e3470d9ea7f280463252612a33531

diff --git a/src/backends/R/rexpression.cpp b/src/backends/R/rexpression.cpp
index 4c194683..426feefb 100644
--- a/src/backends/R/rexpression.cpp
+++ b/src/backends/R/rexpression.cpp
@@ -73,17 +73,13 @@ void RExpression::showFilesAsResult(const QStringList& files)
         qDebug()<<"MimeType: "<<type.name();
         if(type.name() == QLatin1String("application/pdf"))
         {
-            auto* result = new Cantor::ImageResult(QUrl::fromLocalFile(file));
-            result->setRole(Cantor::Result::Role::Plot);
-            setResult(result);
+            setResult(new Cantor::ImageResult(QUrl::fromLocalFile(file)));
             setStatus(Cantor::Expression::Done);
         }
         else
             if (type.name().contains(QLatin1String("image")))
         {
-            auto* result = new Cantor::ImageResult(QUrl::fromLocalFile(file));
-            result->setRole(Cantor::Result::Role::Plot);
-            setResult(result);
+            setResult(new Cantor::ImageResult(QUrl::fromLocalFile(file)));
             setStatus(Cantor::Expression::Done);
         }
         else if(type.inherits(QLatin1String("text/plain"))
diff --git a/src/backends/julia/juliaexpression.cpp b/src/backends/julia/juliaexpression.cpp
index 374ebc1b..6d0bf915 100644
--- a/src/backends/julia/juliaexpression.cpp
+++ b/src/backends/julia/juliaexpression.cpp
@@ -69,9 +69,7 @@ void JuliaExpression::finalize(const QString& output, const QString& error, bool
     } else {
         if (!m_plot_filename.isEmpty() && QFileInfo(m_plot_filename).exists()) {
             // If we have plot in result, show it
-            auto* result = new Cantor::ImageResult(QUrl::fromLocalFile(m_plot_filename));
-            result->setRole(Cantor::Result::Role::Plot);
-            setResult(result);
+            setResult(new Cantor::ImageResult(QUrl::fromLocalFile(m_plot_filename)));
         } else {
             if (!output.isEmpty())
                 setResult(new Cantor::TextResult(output));
diff --git a/src/backends/maxima/maximaexpression.cpp b/src/backends/maxima/maximaexpression.cpp
index 47053239..8091ccb1 100644
--- a/src/backends/maxima/maximaexpression.cpp
+++ b/src/backends/maxima/maximaexpression.cpp
@@ -603,7 +603,6 @@ void MaximaExpression::loadPlotResult()
     }
 
     m_plotResult = plotResult;
-    m_plotResult->setRole(Cantor::Result::Role::Plot);
 
     if (m_plotResultIndex != -1)
     {
diff --git a/src/backends/octave/octaveexpression.cpp b/src/backends/octave/octaveexpression.cpp
index 121f1803..d548ab4d 100644
--- a/src/backends/octave/octaveexpression.cpp
+++ b/src/backends/octave/octaveexpression.cpp
@@ -235,7 +235,6 @@ void OctaveExpression::imageChanged()
     const QUrl& url = QUrl::fromLocalFile(m_plotFilename);
     QByteArray pdfData = file.readAll();
     auto* newResult = new Cantor::PdfResult(url, pdfData);
-    newResult->setRole(Cantor::Result::Role::Plot);
 
     bool found = false;
     for (int i = 0; i < results().size(); i++)
diff --git a/src/backends/python/pythonexpression.cpp b/src/backends/python/pythonexpression.cpp
index 5a5d97e3..6f400578 100644
--- a/src/backends/python/pythonexpression.cpp
+++ b/src/backends/python/pythonexpression.cpp
@@ -143,7 +143,6 @@ void PythonExpression::imageChanged()
         return;
 
     auto* newResult = new Cantor::ImageResult(QUrl::fromLocalFile(m_tempFile->fileName()));
-    newResult->setRole(Cantor::Result::Role::Plot);
     if (result() == nullptr)
         setResult(newResult);
     else
@@ -154,7 +153,6 @@ void PythonExpression::imageChanged()
             {
                 replaceResult(i, newResult);
                 found = true;
-                break;
             }
         if (!found)
             addResult(newResult);
diff --git a/src/backends/qalculate/qalculateexpression.cpp b/src/backends/qalculate/qalculateexpression.cpp
index 6eefcc4a..8ceaea1e 100644
--- a/src/backends/qalculate/qalculateexpression.cpp
+++ b/src/backends/qalculate/qalculateexpression.cpp
@@ -646,9 +646,7 @@ void QalculateExpression::evaluatePlotCommand()
     deletePlotDataParameters(plotDataParameterList);
 
     if (plotInline) {
-        auto* result = new Cantor::ImageResult(QUrl::fromLocalFile(QString::fromStdString(plotParameters.filename)));
-        result->setRole(Cantor::Result::Role::Plot);
-        setResult(result);
+        setResult(new Cantor::ImageResult(QUrl::fromLocalFile(QString::fromStdString(plotParameters.filename))));
         setStatus(Cantor::Expression::Done);
     }
 }
diff --git a/src/backends/sage/sageexpression.cpp b/src/backends/sage/sageexpression.cpp
index a6dedb21..b7addd4a 100644
--- a/src/backends/sage/sageexpression.cpp
+++ b/src/backends/sage/sageexpression.cpp
@@ -220,16 +220,12 @@ void SageExpression::evalFinished()
         if(type.inherits(QLatin1String("image/gif")))
         {
             qDebug()<<"adding animation";
-            auto* result = new Cantor::AnimationResult(QUrl::fromLocalFile(m_imagePath), i18n("Result of %1" , command() ) );
-            result->setRole(Cantor::Result::Role::Plot);
-            addResult(result);
+            addResult( new Cantor::AnimationResult(QUrl::fromLocalFile(m_imagePath), i18n("Result of %1" , command() ) ) );
         }
         else
         {
             qDebug()<<"adding image";
-            auto* result = new Cantor::ImageResult(QUrl::fromLocalFile(m_imagePath ), i18n("Result of %1" , command() ) );
-            result->setRole(Cantor::Result::Role::Plot);
-            addResult(result);
+            addResult( new Cantor::ImageResult(QUrl::fromLocalFile(m_imagePath ), i18n("Result of %1" , command() ) ) );
         }
     }
     setStatus(Cantor::Expression::Done);
diff --git a/src/backends/scilab/scilabexpression.cpp b/src/backends/scilab/scilabexpression.cpp
index 4477b9f2..5c60dcfe 100644
--- a/src/backends/scilab/scilabexpression.cpp
+++ b/src/backends/scilab/scilabexpression.cpp
@@ -97,9 +97,7 @@ void ScilabExpression::parsePlotFile(QString filename)
     qDebug() << "parsePlotFile";
     qDebug() << "ScilabExpression::parsePlotFile: " << filename;
 
-    auto* result = new ScilabPlotResult(QUrl::fromLocalFile(filename));
-    result->setRole(Cantor::Result::Role::Plot);
-    setResult(result);
+    setResult(new ScilabPlotResult(QUrl::fromLocalFile(filename)));
 
     setPlotPending(false);
 
diff --git a/src/cantor.cpp b/src/cantor.cpp
index 703008bb..f7aa6245 100644
--- a/src/cantor.cpp
+++ b/src/cantor.cpp
@@ -464,7 +464,8 @@ void CantorShell::addWorksheet(const QString& backendName)
         m_tabWidget->setCurrentIndex(tab);
         // Setting focus on worksheet view, because Qt clear focus of added widget inside addTab
         // This fix https://bugs.kde.org/show_bug.cgi?id=395976
-        part->widget()->findChild<QGraphicsView*>()->setFocus();
+        if (auto* worksheetView = part->widget()->findChild<QGraphicsView*>())
+            worksheetView->setFocus();
 
         // Force run updateCaption for getting proper backend icon
         QMetaObject::invokeMethod(part, "updateCaption");
@@ -500,11 +501,13 @@ void CantorShell::activateWorksheet(int index)
         m_pluginsVisibility[m_part] = std::move(visiblePanelNames);
 
         auto* wa = m_part->findChild<Cantor::WorksheetAccessInterface*>(Cantor::WorksheetAccessInterface::Name);
-        assert(wa);
         Cantor::PanelPluginHandler::PanelStates states;
-        auto plugins = m_panelHandler.plugins(wa->session());
-        for(auto* plugin : plugins)
-            states.insert(plugin->name(), plugin->saveState());
+        if (wa)
+        {
+            const auto plugins = m_panelHandler.plugins(wa->session());
+            for (auto* plugin : plugins)
+                states.insert(plugin->name(), plugin->saveState());
+        }
 
         m_pluginsStates[m_part] = std::move(states);
     }
@@ -530,7 +533,7 @@ void CantorShell::activateWorksheet(int index)
 
             //update the status bar
             auto* wa = m_part->findChild<Cantor::WorksheetAccessInterface*>(Cantor::WorksheetAccessInterface::Name);
-            if (wa->session())
+            if (wa && wa->session())
             {
                 auto status = wa->session()->status();
                 switch (status) {
diff --git a/src/commandentry.cpp b/src/commandentry.cpp
index bf4e2115..939209be 100644
--- a/src/commandentry.cpp
+++ b/src/commandentry.cpp
@@ -15,6 +15,9 @@
 #include "lib/jupyterutils.h"
 #include "lib/result.h"
 #include "lib/helpresult.h"
+#include "lib/imageresult.h"
+#include "lib/animationresult.h"
+#include "lib/pdfresult.h"
 #include "lib/latexresult.h"
 #include "lib/syntaxhelpobject.h"
 #include "lib/session.h"
@@ -41,6 +44,19 @@
 #include <KColorScheme>
 #include <KSyntaxHighlighting/Definition>
 
+namespace
+{
+bool isVisualResult(Cantor::Result* result)
+{
+    if (!result)
+        return false;
+
+    return result->type() == Cantor::ImageResult::Type
+        || result->type() == Cantor::AnimationResult::Type
+        || result->type() == Cantor::PdfResult::Type;
+}
+}
+
 const QString CommandEntry::Prompt     = QLatin1String(">>> ");
 const QString CommandEntry::MidPrompt  = QLatin1String(">>  ");
 const QString CommandEntry::HidePrompt = QLatin1String(">   ");
@@ -62,7 +78,6 @@ CommandEntry::CommandEntry(Worksheet* worksheet) : WorksheetEntry(worksheet),
     m_backgroundColorActionGroup(nullptr),
     m_backgroundColorMenu(nullptr),
     m_textColorActionGroup(nullptr),
-    m_themeActionGroup(nullptr),
     m_textColorMenu(nullptr),
     m_fontMenu(nullptr),
     m_isExecutionEnabled(true)
@@ -83,11 +98,8 @@ CommandEntry::CommandEntry(Worksheet* worksheet) : WorksheetEntry(worksheet),
 
         m_commandItem->setSyntaxHighlightingMode(highlightingMode);
 
-        if (worksheet)
-        {
-            const auto& parentTheme = worksheet->theme();
-            m_commandItem->setTheme(parentTheme.name());
-        }
+        const auto& parentTheme = worksheet->theme();
+        m_commandItem->setTheme(parentTheme.name());
 
         if (worksheet && worksheet->session() && worksheet->session()->variableModel())
         {
@@ -476,7 +488,7 @@ void CommandEntry::cachePlotResultMetadata()
 
     for (auto* result : m_expression->results())
     {
-        if (!result || result->role() != Cantor::Result::Role::Plot)
+        if (!isVisualResult(result))
             continue;
 
         m_plotResultMetadataToRestore.append({result->resultId(), result->displayName()});
@@ -485,7 +497,7 @@ void CommandEntry::cachePlotResultMetadata()
 
 void CommandEntry::restorePlotResultMetadata(Cantor::Result* result, int plotIndex)
 {
-    if (!result || result->role() != Cantor::Result::Role::Plot)
+    if (!isVisualResult(result))
         return;
 
     if (plotIndex < 0 || plotIndex >= m_plotResultMetadataToRestore.size())
@@ -939,14 +951,14 @@ void CommandEntry::updateEntry()
         for (int i = 0; i < m_resultItems.size() && i < expr->results().size(); ++i)
         {
             auto* result = expr->results().at(i);
-            if (result && result->role() == Cantor::Result::Role::Plot)
+            if (isVisualResult(result))
                 ++plotIndex;
         }
 
         for (int i = m_resultItems.size(); i < expr->results().size(); i++)
         {
             auto* result = expr->results()[i];
-            if (result && result->role() == Cantor::Result::Role::Plot)
+            if (isVisualResult(result))
                 restorePlotResultMetadata(result, plotIndex++);
 
             if (auto* resultItem = ResultItem::create(this, result))
diff --git a/src/commandentry.h b/src/commandentry.h
index 98a93038..ef078065 100644
--- a/src/commandentry.h
+++ b/src/commandentry.h
@@ -157,7 +157,6 @@ class CommandEntry : public WorksheetEntry
     QActionGroup* m_backgroundColorActionGroup;
     QMenu* m_backgroundColorMenu;
     QActionGroup* m_textColorActionGroup;
-    QActionGroup* m_themeActionGroup;
     QColor m_defaultDefaultTextColor;
     QMenu* m_textColorMenu;
     QMenu* m_fontMenu;
diff --git a/src/lib/panelpluginhandler.cpp b/src/lib/panelpluginhandler.cpp
index 91902cb8..3bdf3016 100644
--- a/src/lib/panelpluginhandler.cpp
+++ b/src/lib/panelpluginhandler.cpp
@@ -106,7 +106,14 @@ QList<PanelPlugin*> PanelPluginHandler::activePluginsForSession(Session* session
         }
 
         if (previousPluginStates.contains(plugin->name()))
-            plugin->restoreState(previousPluginStates[plugin->name()]);
+        {
+            PanelPlugin::State state = previousPluginStates[plugin->name()];
+            // A worksheet can replace its Session while loading a file. Panel
+            // UI state remains reusable, but the stored Session pointer may no
+            // longer be alive when returning to the worksheet.
+            state.session = session;
+            plugin->restoreState(state);
+        }
         else
         {
             Cantor::PanelPlugin::State initState;
diff --git a/src/lib/result.cpp b/src/lib/result.cpp
index 3bdaec87..c1861d74 100644
--- a/src/lib/result.cpp
+++ b/src/lib/result.cpp
@@ -24,7 +24,6 @@ class Cantor::ResultPrivate
     QJsonObject* jupyterMetadata{nullptr};
     QString resultId;
     QString displayName;
-    Result::Role role{Result::Role::Generic};
     int executionIndex{-1};
 };
 
@@ -65,7 +64,6 @@ QJsonObject Cantor::Result::jupyterMetadata() const
     QJsonObject cantorMetadata = metadata.value(JupyterUtils::cantorMetadataKey).toObject();
 
     cantorMetadata.insert(QLatin1String("result-id"), d->resultId);
-    cantorMetadata.insert(QLatin1String("result-role"), roleToString(d->role));
     if (d->displayName.isEmpty())
         cantorMetadata.remove(QLatin1String("result-title"));
     else
@@ -86,8 +84,6 @@ void Cantor::Result::setJupyterMetadata(const QJsonObject& metadata)
     if (!storedResultId.isEmpty())
         d->resultId = storedResultId;
 
-    d->role = roleFromString(cantorMetadata.value(QLatin1String("result-role")).toString());
-
     const QJsonValue storedTitle = cantorMetadata.value(QLatin1String("result-title"));
     if (storedTitle.isString())
         d->displayName = storedTitle.toString().trimmed();
@@ -133,41 +129,9 @@ void Cantor::Result::setDisplayName(const QString& name)
     d->displayName = name.trimmed();
 }
 
-Cantor::Result::Role Cantor::Result::role() const
-{
-    return d->role;
-}
-
-void Cantor::Result::setRole(Role role)
-{
-    d->role = role;
-}
-
-QString Cantor::Result::roleToString(Role role)
-{
-    switch (role)
-    {
-        case Role::Plot:
-            return QStringLiteral("plot");
-        case Role::Generic:
-            return QStringLiteral("generic");
-    }
-
-    return QStringLiteral("generic");
-}
-
-Cantor::Result::Role Cantor::Result::roleFromString(const QString& roleName)
-{
-    if (roleName == QLatin1String("plot"))
-        return Role::Plot;
-
-    return Role::Generic;
-}
-
 void Cantor::Result::applyXmlResultMetadata(QDomElement& element) const
 {
     element.setAttribute(QLatin1String("result-id"), d->resultId);
-    element.setAttribute(QLatin1String("result-role"), roleToString(d->role));
     if (!d->displayName.isEmpty())
         element.setAttribute(QLatin1String("result-title"), d->displayName);
 }
@@ -178,6 +142,5 @@ void Cantor::Result::loadXmlResultMetadata(const QDomElement& element)
     if (!storedResultId.isEmpty())
         d->resultId = storedResultId;
 
-    d->role = roleFromString(element.attribute(QLatin1String("result-role")));
     d->displayName = element.attribute(QLatin1String("result-title")).trimmed();
 }
diff --git a/src/lib/result.h b/src/lib/result.h
index b95e36d6..8e6ba12b 100644
--- a/src/lib/result.h
+++ b/src/lib/result.h
@@ -25,13 +25,6 @@ class ResultPrivate;
 class CANTOR_EXPORT Result
 {
   public:
-    /** Describes how UI code should treat this result. */
-    enum class Role
-    {
-        Generic,
-        Plot
-    };
-
     /**
      * Default constructor
      */
@@ -113,13 +106,6 @@ class CANTOR_EXPORT Result
 
     void setDisplayName(const QString& name);
 
-    /** UI role assigned by the backend or loader. */
-    Role role() const;
-    void setRole(Role role);
-
-    static QString roleToString(Role role);
-    static Role roleFromString(const QString& roleName);
-
     void applyXmlResultMetadata(QDomElement& element) const;
     void loadXmlResultMetadata(const QDomElement& element);
 
diff --git a/src/panelplugins/tocpanel/tocpanelplugin.cpp b/src/panelplugins/tocpanel/tocpanelplugin.cpp
index 4f96fa7c..473537e2 100644
--- a/src/panelplugins/tocpanel/tocpanelplugin.cpp
+++ b/src/panelplugins/tocpanel/tocpanelplugin.cpp
@@ -12,16 +12,19 @@
 #include <QDebug>
 #include <QItemSelectionModel>
 #include <QKeyEvent>
+#include <QLabel>
 #include <QLineEdit>
 #include <QMenu>
 #include <QModelIndex>
 #include <QScopedValueRollback>
+#include <QSignalBlocker>
 #include <QStandardItem>
 #include <QStyledItemDelegate>
 #include <QTreeView>
 #include <QTimer>
 #include <QVariantList>
 #include <QVariantMap>
+#include <QVBoxLayout>
 #include <QWidget>
 
 #include <KLocalizedString>
@@ -119,10 +122,8 @@ TableOfContentPanelPlugin::TableOfContentPanelPlugin(QObject* parent, const QLis
 
 TableOfContentPanelPlugin::~TableOfContentPanelPlugin()
 {
-    if (m_mainWidget)
-    {
-        m_mainWidget->deleteLater();
-    }
+    if (m_containerWidget)
+        m_containerWidget->deleteLater();
 }
 
 QWidget* TableOfContentPanelPlugin::widget()
@@ -130,7 +131,7 @@ QWidget* TableOfContentPanelPlugin::widget()
     if (!m_mainWidget)
         constructMainWidget();
 
-    return m_mainWidget;
+    return m_containerWidget;
 }
 
 void TableOfContentPanelPlugin::connectToShell(QObject* cantorShell)
@@ -165,6 +166,12 @@ void TableOfContentPanelPlugin::handleClicked(const QModelIndex& index)
         return;
 
     Q_EMIT requestNavigateToTocNode(nodeId);
+
+    // Navigation focuses the target worksheet entry. Keep focus in the TOC
+    // when navigation originated here so subsequent TOC shortcuts keep
+    // operating on the selected node.
+    if (m_mainWidget)
+        m_mainWidget->setFocus(Qt::MouseFocusReason);
 }
 
 void TableOfContentPanelPlugin::handleDoubleClicked(const QModelIndex& index)
@@ -174,7 +181,20 @@ void TableOfContentPanelPlugin::handleDoubleClicked(const QModelIndex& index)
 
 void TableOfContentPanelPlugin::constructMainWidget()
 {
-    auto* view = new QTreeView;
+    auto* container = new QWidget;
+    auto* layout = new QVBoxLayout(container);
+    layout->setContentsMargins(0, 0, 0, 0);
+    layout->setSpacing(4);
+
+    auto* searchEdit = new QLineEdit(container);
+    searchEdit->setClearButtonEnabled(true);
+    searchEdit->setPlaceholderText(i18n("Search Table of Contents"));
+
+    auto* view = new QTreeView(container);
+    auto* emptyLabel = new QLabel(container);
+    emptyLabel->setAlignment(Qt::AlignCenter);
+    emptyLabel->setWordWrap(true);
+    emptyLabel->hide();
 
     view->setEditTriggers(QAbstractItemView::NoEditTriggers);
     view->setSelectionBehavior(QAbstractItemView::SelectRows);
@@ -186,6 +206,7 @@ void TableOfContentPanelPlugin::constructMainWidget()
     view->setContextMenuPolicy(Qt::CustomContextMenu);
     view->installEventFilter(this);
     view->viewport()->installEventFilter(this);
+    searchEdit->installEventFilter(this);
 
     auto* delegate = new HierarchyNameDelegate(NameRole, NodeIdRole, HierarchyIdRole, NodeTypeRole, CustomTitleRole, EntryIdRole, ResultIdRole, view);
 
@@ -199,30 +220,168 @@ void TableOfContentPanelPlugin::constructMainWidget()
     connect(view, &QTreeView::collapsed, this, &TableOfContentPanelPlugin::handleCollapsed);
     connect(delegate, &QAbstractItemDelegate::commitData, this, &TableOfContentPanelPlugin::handleEditorCommit);
     connect(delegate, &QAbstractItemDelegate::closeEditor, this, &TableOfContentPanelPlugin::handleEditorClosed);
+    connect(searchEdit, &QLineEdit::textChanged, this, [this](const QString& text)
+    {
+        m_searchText = text.trimmed();
+        rebuildModel();
+    });
 
+    layout->addWidget(searchEdit);
+    layout->addWidget(emptyLabel, 1);
+    layout->addWidget(view, 1);
+
+    m_containerWidget = container;
+    m_searchEdit = searchEdit;
+    m_emptyLabel = emptyLabel;
     m_mainWidget = view;
     rebuildModel();
 }
 
 bool TableOfContentPanelPlugin::eventFilter(QObject* watched, QEvent* event)
 {
-    if (m_mainWidget && (watched == m_mainWidget || watched == m_mainWidget->viewport()) && event->type() == QEvent::KeyPress)
+    const bool watchesToc = m_mainWidget && (watched == m_mainWidget || watched == m_mainWidget->viewport());
+
+    if (watchesToc && event->type() == QEvent::ShortcutOverride)
     {
         auto* keyEvent = static_cast<QKeyEvent*>(event);
+        const bool tocShortcut = keyEvent->matches(QKeySequence::Find)
+            || keyEvent->key() == Qt::Key_F2
+            || keyEvent->key() == Qt::Key_Delete
+            || keyEvent->key() == Qt::Key_Menu
+            || (keyEvent->key() == Qt::Key_F10 && keyEvent->modifiers() & Qt::ShiftModifier);
+
+        if (tocShortcut)
+        {
+            keyEvent->accept();
+            return true;
+        }
+    }
+
+    if (m_searchEdit && watched == m_searchEdit && event->type() == QEvent::KeyPress)
+    {
+        auto* keyEvent = static_cast<QKeyEvent*>(event);
+        if (keyEvent->key() == Qt::Key_Escape && !m_searchEdit->text().isEmpty())
+        {
+            m_searchEdit->clear();
+            return true;
+        }
+    }
+
+    if (watchesToc && event->type() == QEvent::KeyPress)
+    {
+        auto* keyEvent = static_cast<QKeyEvent*>(event);
+        if (keyEvent->matches(QKeySequence::Find) && m_searchEdit)
+        {
+            m_searchEdit->setFocus();
+            m_searchEdit->selectAll();
+            return true;
+        }
+
         if (keyEvent->key() == Qt::Key_F2 && !m_readOnly)
         {
             beginRename(m_mainWidget->currentIndex());
             return true;
         }
+
+        const QModelIndex index = m_mainWidget->currentIndex();
+        if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) && index.isValid())
+        {
+            handleClicked(index);
+            return true;
+        }
+
+        if ((keyEvent->key() == Qt::Key_Menu || (keyEvent->key() == Qt::Key_F10 && keyEvent->modifiers() & Qt::ShiftModifier)) && index.isValid())
+        {
+            showContextMenuForIndex(index);
+            return true;
+        }
+
+        if (keyEvent->key() == Qt::Key_Delete && !m_readOnly && index.isValid())
+        {
+            deleteItemAtIndex(index);
+            return true;
+        }
+
+        if (keyEvent->key() == Qt::Key_Escape && m_searchEdit && !m_searchEdit->text().isEmpty())
+        {
+            m_searchEdit->clear();
+            return true;
+        }
     }
 
     return Cantor::PanelPlugin::eventFilter(watched, event);
 }
 
+void TableOfContentPanelPlugin::deleteItemAtIndex(const QModelIndex& index)
+{
+    if (!index.isValid() || m_readOnly)
+        return;
+
+    const QString hierarchyId = index.data(HierarchyIdRole).toString();
+    const QString nodeType = index.data(NodeTypeRole).toString();
+    const QString displayText = index.data(DisplayTextRole).toString();
+
+    if (!hierarchyId.isEmpty())
+    {
+        const QString headingTitle = displayText.isEmpty() ? i18n("Heading") : displayText;
+        const auto result = KMessageBox::warningTwoActions(
+            m_mainWidget,
+            i18n("Do you really want to delete the heading \"%1\"? Its contents will be kept. This action cannot be undone.", headingTitle),
+            i18n("Delete Heading"),
+            KStandardGuiItem::remove(),
+            KStandardGuiItem::cancel());
+
+        if (result == KMessageBox::PrimaryAction)
+            Q_EMIT requestDeleteHierarchyEntry(hierarchyId, false);
+        return;
+    }
+
+    const QString commandId = index.data(EntryIdRole).toString();
+    if (nodeType == TocNodeTypeCommand && !commandId.isEmpty())
+    {
+        if (Settings::warnAboutEntryDelete())
+        {
+            const QString commandTitle = displayText.isEmpty() ? i18n("Command") : displayText;
+            const auto result = KMessageBox::warningTwoActions(
+                m_mainWidget,
+                i18n("Do you really want to delete \"%1\"? This action cannot be undone.", commandTitle),
+                i18n("Delete Command"),
+                KStandardGuiItem::remove(),
+                KStandardGuiItem::cancel());
+
+            if (result != KMessageBox::PrimaryAction)
+                return;
+        }
+
+        Q_EMIT requestDeleteCommandEntry(commandId);
+        return;
+    }
+
+    const QString resultId = index.data(ResultIdRole).toString();
+    if (nodeType == TocNodeTypePlot && !commandId.isEmpty() && !resultId.isEmpty())
+    {
+        const QString plotTitle = displayText.isEmpty() ? i18n("Plot") : displayText;
+        const auto result = KMessageBox::warningTwoActions(
+            m_mainWidget,
+            i18n("Do you really want to delete \"%1\"? This action cannot be undone.", plotTitle),
+            i18n("Delete Plot"),
+            KStandardGuiItem::remove(),
+            KStandardGuiItem::cancel());
+
+        if (result == KMessageBox::PrimaryAction)
+            Q_EMIT requestDeletePlot(commandId, resultId);
+    }
+}
+
 void TableOfContentPanelPlugin::rebuildModel()
 {
+    if (!m_modelFilteredBySearch)
+        saveCurrentExpansionState();
+
     QScopedValueRollback<bool> guard(m_updatingModel, true);
 
+    updateSearchVisibility();
+
     m_model.clear();
     m_itemsByNodeId.clear();
 
@@ -254,6 +413,8 @@ void TableOfContentPanelPlugin::rebuildModel()
         item->setData(node.resultIndex, ResultIndexRole);
         item->setData(node.editable, EditableRole);
         item->setData(node.navigable, NavigableRole);
+        item->setData(node.canPromote, CanPromoteRole);
+        item->setData(node.canDemote, CanDemoteRole);
 
         parentItem->appendRow(item);
         visibleItems[i] = item;
@@ -262,7 +423,38 @@ void TableOfContentPanelPlugin::rebuildModel()
     }
 
     restoreExpansionState();
+    if (!m_searchText.isEmpty() && m_mainWidget)
+        m_mainWidget->expandAll();
     updateCurrentNodeSelection();
+    const bool isEmpty = m_model.rowCount() == 0;
+    if (m_emptyLabel)
+    {
+        m_emptyLabel->setText(m_searchText.isEmpty()
+            ? i18n("No visible items in the Table of Contents")
+            : i18n("No matching items"));
+        m_emptyLabel->setVisible(isEmpty);
+    }
+    if (m_mainWidget)
+        m_mainWidget->setVisible(!isEmpty);
+    m_modelFilteredBySearch = !m_searchText.isEmpty();
+}
+
+void TableOfContentPanelPlugin::saveCurrentExpansionState()
+{
+    if (!m_mainWidget)
+        return;
+
+    for (auto it = m_itemsByNodeId.cbegin(); it != m_itemsByNodeId.cend(); ++it)
+    {
+        QStandardItem* item = it.value();
+        if (!item || !item->hasChildren())
+            continue;
+
+        if (m_mainWidget->isExpanded(item->index()))
+            m_expandedNodeIds.insert(it.key());
+        else
+            m_expandedNodeIds.remove(it.key());
+    }
 }
 
 void TableOfContentPanelPlugin::restoreExpansionState()
@@ -336,6 +528,15 @@ void TableOfContentPanelPlugin::updateCurrentNodeSelection()
 void TableOfContentPanelPlugin::restoreState(const Cantor::PanelPlugin::State& state)
 {
     cancelEditorSession();
+
+    if (m_searchEdit)
+    {
+        const QSignalBlocker blocker(m_searchEdit);
+        m_searchEdit->clear();
+    }
+    m_searchText.clear();
+    m_modelFilteredBySearch = false;
+
     clearNodes();
 
     resetVisibilityToDefaults();
@@ -442,6 +643,9 @@ void TableOfContentPanelPlugin::handleTocNodeChanges(const QVariantList& nodes)
 
 void TableOfContentPanelPlugin::applyTocNodeChanges(const QVariantList& nodes)
 {
+    if (!m_modelFilteredBySearch)
+        saveCurrentExpansionState();
+
     clearNodes();
     m_nodes.reserve(nodes.size());
 
@@ -491,6 +695,8 @@ void TableOfContentPanelPlugin::applyTocNodeChanges(const QVariantList& nodes)
         tocNode.depth = node.value(QStringLiteral("depth"), 0).toInt();
         tocNode.editable = node.value(QStringLiteral("editable"), false).toBool();
         tocNode.navigable = node.value(QStringLiteral("navigable"), false).toBool();
+        tocNode.canPromote = node.value(QStringLiteral("canPromote"), false).toBool();
+        tocNode.canDemote = node.value(QStringLiteral("canDemote"), false).toBool();
 
         if (tocNode.displayText.isEmpty())
             tocNode.displayText = tocNode.title;
@@ -695,7 +901,11 @@ bool TableOfContentPanelPlugin::shouldDisplayNode(int index) const
     if (index < 0 || index >= m_nodes.size())
         return false;
 
-    const QString& type = m_nodes.at(index).type;
+    const TocNode& node = m_nodes.at(index);
+    if (!m_searchText.isEmpty() && !m_searchVisibleNodeIds.contains(node.id))
+        return false;
+
+    const QString& type = node.type;
 
     if (type == TocNodeTypeChapter)
         return m_showChapters;
@@ -709,6 +919,27 @@ bool TableOfContentPanelPlugin::shouldDisplayNode(int index) const
     return true;
 }
 
+void TableOfContentPanelPlugin::updateSearchVisibility()
+{
+    m_searchVisibleNodeIds.clear();
+    if (m_searchText.isEmpty())
+        return;
+
+    for (int index = 0; index < m_nodes.size(); ++index)
+    {
+        const TocNode& node = m_nodes.at(index);
+        if (!node.title.contains(m_searchText, Qt::CaseInsensitive)
+            && !node.displayText.contains(m_searchText, Qt::CaseInsensitive)
+            && !node.customTitle.contains(m_searchText, Qt::CaseInsensitive))
+        {
+            continue;
+        }
+
+        for (int visibleIndex = index; visibleIndex >= 0; visibleIndex = m_nodes.at(visibleIndex).parentIndex)
+            m_searchVisibleNodeIds.insert(m_nodes.at(visibleIndex).id);
+    }
+}
+
 int TableOfContentPanelPlugin::findVisibleAncestorIndex(int index) const
 {
     while (index >= 0)
@@ -824,6 +1055,9 @@ void TableOfContentPanelPlugin::handleReadOnlyChanged(bool readOnly)
 
 void TableOfContentPanelPlugin::handleExpanded(const QModelIndex& index)
 {
+    if (m_updatingModel)
+        return;
+
     const QString nodeId = index.data(NodeIdRole).toString();
 
     if (!nodeId.isEmpty())
@@ -832,12 +1066,24 @@ void TableOfContentPanelPlugin::handleExpanded(const QModelIndex& index)
 
 void TableOfContentPanelPlugin::handleCollapsed(const QModelIndex& index)
 {
+    if (m_updatingModel)
+        return;
+
     const QString nodeId = index.data(NodeIdRole).toString();
 
     if (!nodeId.isEmpty())
         m_expandedNodeIds.remove(nodeId);
 }
 
+void TableOfContentPanelPlugin::showContextMenuForIndex(const QModelIndex& index)
+{
+    if (!m_mainWidget || !index.isValid())
+        return;
+
+    m_mainWidget->setCurrentIndex(index);
+    handleContextMenuRequested(m_mainWidget->visualRect(index).center());
+}
+
 void TableOfContentPanelPlugin::handleContextMenuRequested(const QPoint& position)
 {
     if (!m_mainWidget)
@@ -858,8 +1104,6 @@ void TableOfContentPanelPlugin::handleContextMenuRequested(const QPoint& positio
 
         auto* item = m_model.itemFromIndex(index);
 
-        const int depth = index.data(DepthRole).toInt();
-
         if (!hierarchyId.isEmpty())
         {
             QAction* goToHeadingAction = menu.addAction(i18n("Go to Heading"));
@@ -884,14 +1128,14 @@ void TableOfContentPanelPlugin::handleContextMenuRequested(const QPoint& positio
             menu.addSeparator();
 
             QAction* promoteAction = menu.addAction(QIcon::fromTheme(QStringLiteral("format-indent-less")), i18n("Promote Heading"));
-            promoteAction->setEnabled(!m_readOnly && depth > 0);
+            promoteAction->setEnabled(!m_readOnly && index.data(CanPromoteRole).toBool());
             connect(promoteAction, &QAction::triggered, this, [this, hierarchyId]()
             {
                 Q_EMIT requestChangeHierarchyLevel(hierarchyId, -1);
             });
 
             QAction* demoteAction = menu.addAction(QIcon::fromTheme(QStringLiteral("format-indent-more")), i18n("Demote Heading"));
-            demoteAction->setEnabled(!m_readOnly && depth < 5);
+            demoteAction->setEnabled(!m_readOnly && index.data(CanDemoteRole).toBool());
             connect(demoteAction, &QAction::triggered, this, [this, hierarchyId]()
             {
                 Q_EMIT requestChangeHierarchyLevel(hierarchyId, 1);
diff --git a/src/panelplugins/tocpanel/tocpanelplugin.h b/src/panelplugins/tocpanel/tocpanelplugin.h
index 6acc0335..1f45df9f 100644
--- a/src/panelplugins/tocpanel/tocpanelplugin.h
+++ b/src/panelplugins/tocpanel/tocpanelplugin.h
@@ -19,6 +19,8 @@
 
 class QWidget;
 class QEvent;
+class QLineEdit;
+class QLabel;
 class QModelIndex;
 class QMenu;
 class QStandardItem;
@@ -80,7 +82,9 @@ private:
         CustomTitleRole,
         ResultIndexRole,
         EditableRole,
-        NavigableRole
+        NavigableRole,
+        CanPromoteRole,
+        CanDemoteRole
     };
 
     struct TocNode
@@ -100,6 +104,8 @@ private:
         int parentIndex{-1};
         bool editable{false};
         bool navigable{false};
+        bool canPromote{false};
+        bool canDemote{false};
     };
 
     void constructMainWidget();
@@ -119,8 +125,15 @@ private:
     void handleEditorClosed();
     void finishEditorSession();
     void cancelEditorSession();
+    void updateSearchVisibility();
+    void saveCurrentExpansionState();
+    void showContextMenuForIndex(const QModelIndex& index);
+    void deleteItemAtIndex(const QModelIndex& index);
 
 private:
+    QPointer<QWidget> m_containerWidget;
+    QPointer<QLineEdit> m_searchEdit;
+    QPointer<QLabel> m_emptyLabel;
     QPointer<QTreeView> m_mainWidget;
     QStandardItemModel m_model;
 
@@ -136,9 +149,12 @@ private:
     bool m_readOnly{false};
 
     QSet<QString> m_expandedNodeIds;
+    QSet<QString> m_searchVisibleNodeIds;
 
     QString m_currentNodeId;
+    QString m_searchText;
     bool m_updatingModel{false};
+    bool m_modelFilteredBySearch{false};
     bool m_expansionStateInitialized{false};
     bool m_editorActive{false};
     bool m_hasPendingNodeSnapshot{false};
diff --git a/src/panelplugins/variablemgr/variablemanagerwidget.cpp b/src/panelplugins/variablemgr/variablemanagerwidget.cpp
index ada4bfbb..c2675e03 100644
--- a/src/panelplugins/variablemgr/variablemanagerwidget.cpp
+++ b/src/panelplugins/variablemgr/variablemanagerwidget.cpp
@@ -107,7 +107,20 @@ VariableManagerWidget::VariableManagerWidget(Cantor::Session* session, QWidget*
 
 void VariableManagerWidget::setSession(Cantor::Session* session)
 {
+    if (m_model)
+        disconnect(m_model, nullptr, this, nullptr);
+
+    if (m_treeView)
+        m_treeView->setModel(nullptr);
+
     m_session = session;
+    m_model = nullptr;
+
+    m_loadBtn->setEnabled(true);
+    m_saveBtn->setEnabled(true);
+    m_newBtn->setEnabled(true);
+    m_clearBtn->setEnabled(true);
+
     if (session)
     {
         m_model = session->variableDataModel();
diff --git a/src/worksheethierarchymanager.cpp b/src/worksheethierarchymanager.cpp
index 3d0c272e..3c2066cf 100644
--- a/src/worksheethierarchymanager.cpp
+++ b/src/worksheethierarchymanager.cpp
@@ -125,6 +125,52 @@ QVariantList WorksheetHierarchyManager::collectTocNodes() const
             const QString displayText = hierarchyEntry->hierarchyText().isEmpty()
                 ? hierarchyEntry->text()
                 : hierarchyEntry->hierarchyText() + QLatin1Char(' ') + hierarchyEntry->text();
+            const int minimumLevel = static_cast<int>(HierarchyEntry::HierarchyLevel::Chapter);
+            const int maximumLevel = static_cast<int>(HierarchyEntry::HierarchyLevel::Subparagraph);
+            const int currentLevel = static_cast<int>(hierarchyEntry->level());
+
+            bool canDemote = currentLevel < maximumLevel;
+            if (canDemote)
+            {
+                bool hasPreviousSibling = false;
+                for (auto* previous = hierarchyEntry->previous(); previous; previous = previous->previous())
+                {
+                    if (previous->type() != HierarchyEntry::Type)
+                        continue;
+
+                    const int previousLevel = static_cast<int>(static_cast<HierarchyEntry*>(previous)->level());
+                    if (previousLevel < currentLevel)
+                        break;
+                    if (previousLevel == currentLevel)
+                    {
+                        hasPreviousSibling = true;
+                        break;
+                    }
+                }
+
+                canDemote = hasPreviousSibling;
+            }
+
+            if (canDemote)
+            {
+                WorksheetEntry* firstSubentry = hierarchyEntry->hasHiddenSubentries()
+                    ? hierarchyEntry->hiddenSubentries()
+                    : hierarchyEntry->next();
+                for (auto* child = firstSubentry; child; child = child->next())
+                {
+                    if (child->type() != HierarchyEntry::Type)
+                        continue;
+
+                    const int childLevel = static_cast<int>(static_cast<HierarchyEntry*>(child)->level());
+                    if (childLevel <= currentLevel)
+                        break;
+                    if (childLevel >= maximumLevel)
+                    {
+                        canDemote = false;
+                        break;
+                    }
+                }
+            }
 
             QVariantMap node;
             node.insert(QStringLiteral("id"), hierarchyId);
@@ -136,6 +182,8 @@ QVariantList WorksheetHierarchyManager::collectTocNodes() const
             node.insert(QStringLiteral("depth"), depth);
             node.insert(QStringLiteral("editable"), true);
             node.insert(QStringLiteral("navigable"), true);
+            node.insert(QStringLiteral("canPromote"), currentLevel > minimumLevel);
+            node.insert(QStringLiteral("canDemote"), canDemote);
             node.insert(QStringLiteral("hierarchyId"), hierarchyId);
             node.insert(QStringLiteral("resultIndex"), -1);
             node.insert(QStringLiteral("entryId"), hierarchyId);
@@ -311,7 +359,7 @@ QString WorksheetHierarchyManager::plotTocDisplayText(CommandEntry* entry, Canto
 
 bool WorksheetHierarchyManager::isPlotResult(Cantor::Result* result) const
 {
-    if (!result || result->role() != Cantor::Result::Role::Plot)
+    if (!result)
         return false;
 
     return result->type() == Cantor::ImageResult::Type
@@ -1281,6 +1329,31 @@ void WorksheetHierarchyManager::updateCurrentHierarchyFromView(const QRectF& vie
         activeEntry = entry;
     }
 
+    if (activeEntry && activeEntry->type() == CommandEntry::Type)
+    {
+        auto* commandEntry = static_cast<CommandEntry*>(activeEntry);
+        QString activePlotNodeId;
+
+        for (int index = 0; index < commandEntry->resultItemCount(); ++index)
+        {
+            ResultItem* resultItem = commandEntry->resultItemAt(index);
+            if (!resultItem || !isPlotResult(resultItem->result()))
+                continue;
+
+            QGraphicsObject* object = resultItem->graphicsObject();
+            if (!object || !object->isVisible() || object->sceneBoundingRect().top() > activationY)
+                continue;
+
+            activePlotNodeId = buildPlotNodeId(commandEntry->commandId(), resultItem->result()->resultId());
+        }
+
+        if (!activePlotNodeId.isEmpty())
+        {
+            setCurrentTocNode(activePlotNodeId);
+            return;
+        }
+    }
+
     updateCurrentHierarchy(activeEntry);
 }
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.