[education/cantor] src: Add custom command titles in the structure navigator

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

Add custom command titles in the structure navigator

M  +14   -0    src/cantor.cpp
M  +4    -0    src/cantor.h
M  +2    -0    src/cantor_part.cpp
M  +2    -0    src/cantor_part.h
M  +69   -1    src/commandentry.cpp
M  +9    -0    src/commandentry.h
M  +56   -7    src/panelplugins/tocpanel/tocpanelplugin.cpp
M  +2    -0    src/panelplugins/tocpanel/tocpanelplugin.h
M  +10   -0    src/worksheet.cpp
M  +2    -0    src/worksheet.h
M  +18   -2    src/worksheetentry.cpp
M  +52   -4    src/worksheethierarchymanager.cpp
M  +3    -7    src/worksheethierarchymanager.h

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

diff --git a/src/cantor.cpp b/src/cantor.cpp
index 4c0efda2..703008bb 100644
--- a/src/cantor.cpp
+++ b/src/cantor.cpp
@@ -68,6 +68,8 @@ CantorShell::CantorShell() : KParts::MainWindow(), m_tabWidget(new QTabWidget(th
     connect(this, &CantorShell::requestChangeHierarchyLevel, this, &CantorShell::forwardChangeHierarchyLevel);
     connect(this, &CantorShell::requestDeleteHierarchyEntry, this, &CantorShell::forwardDeleteHierarchyEntry);
     connect(this, &CantorShell::requestRenameHierarchyEntry, this, &CantorShell::forwardRenameHierarchyEntry);
+    connect(this, &CantorShell::requestRenameCommandEntry, this, &CantorShell::forwardRenameCommandEntry);
+    connect(this, &CantorShell::requestDeleteCommandEntry, this, &CantorShell::forwardDeleteCommandEntry);
     connect(this, &CantorShell::requestRenamePlot, this, &CantorShell::forwardRenamePlot);
     connect(this, &CantorShell::requestDeletePlot, this, &CantorShell::forwardDeletePlot);
 
@@ -266,6 +268,18 @@ void CantorShell::forwardDeleteHierarchyEntry(const QString& hierarchyId, bool d
         QMetaObject::invokeMethod(m_part, "requestDeleteHierarchyEntry", Qt::DirectConnection, Q_ARG(QString, hierarchyId), Q_ARG(bool, deleteContents));
 }
 
+void CantorShell::forwardRenameCommandEntry(const QString& commandId, const QString& newTitle)
+{
+    if (m_part)
+        QMetaObject::invokeMethod(m_part, "requestRenameCommandEntry", Qt::DirectConnection, Q_ARG(QString, commandId), Q_ARG(QString, newTitle));
+}
+
+void CantorShell::forwardDeleteCommandEntry(const QString& commandId)
+{
+    if (m_part)
+        QMetaObject::invokeMethod(m_part, "requestDeleteCommandEntry", Qt::DirectConnection, Q_ARG(QString, commandId));
+}
+
 void CantorShell::forwardRenamePlot(const QString& commandId, const QString& resultId, const QString& newTitle)
 {
     if (m_part)
diff --git a/src/cantor.h b/src/cantor.h
index 9fd7880d..282770b0 100644
--- a/src/cantor.h
+++ b/src/cantor.h
@@ -73,6 +73,8 @@ Q_SIGNALS:
     void requestRenameHierarchyEntry(QString hierarchyId, QString newName);
     void requestChangeHierarchyLevel(QString hierarchyId, int levelDelta);
     void requestDeleteHierarchyEntry(QString hierarchyId, bool deleteContents);
+    void requestRenameCommandEntry(QString commandId, QString newTitle);
+    void requestDeleteCommandEntry(QString commandId);
     void requestRenamePlot(QString commandId, QString resultId, QString newTitle);
     void requestDeletePlot(QString commandId, QString resultId);
     void tocReadOnlyChanged(bool readOnly);
@@ -98,6 +100,8 @@ private Q_SLOTS:
     void forwardRenameHierarchyEntry(const QString& hierarchyId, const QString& newName);
     void forwardChangeHierarchyLevel(const QString& hierarchyId, int levelDelta);
     void forwardDeleteHierarchyEntry(const QString& hierarchyId, bool deleteContents);
+    void forwardRenameCommandEntry(const QString& commandId, const QString& newTitle);
+    void forwardDeleteCommandEntry(const QString& commandId);
     void forwardRenamePlot(const QString& commandId, const QString& resultId, const QString& newTitle);
     void forwardDeletePlot(const QString& commandId, const QString& resultId);
     void handleTocNodesChanged(QVariantList nodes);
diff --git a/src/cantor_part.cpp b/src/cantor_part.cpp
index ad3e43d9..aeb2e4bf 100644
--- a/src/cantor_part.cpp
+++ b/src/cantor_part.cpp
@@ -139,6 +139,8 @@ CantorPart::CantorPart(QObject* parent, const QVariantList& args)
     connect(this, &CantorPart::requestRenameHierarchyEntry, m_worksheet, &Worksheet::renameHierarchyEntry);
     connect(this, &CantorPart::requestChangeHierarchyLevel, m_worksheet, &Worksheet::changeHierarchyLevel);
     connect(this, &CantorPart::requestDeleteHierarchyEntry, m_worksheet, &Worksheet::deleteHierarchyEntry);
+    connect(this, &CantorPart::requestRenameCommandEntry, m_worksheet, &Worksheet::renameCommandEntry);
+    connect(this, &CantorPart::requestDeleteCommandEntry, m_worksheet, &Worksheet::deleteCommandEntry);
     connect(this, &CantorPart::requestRenamePlot, m_worksheet, &Worksheet::renamePlot);
     connect(this, &CantorPart::requestDeletePlot, m_worksheet, &Worksheet::deletePlot);
     connect(this, &CantorPart::requestTocNodeSnapshot, m_worksheet, &Worksheet::emitTocNodeSnapshot);
diff --git a/src/cantor_part.h b/src/cantor_part.h
index 3076ecb1..b036aeac 100644
--- a/src/cantor_part.h
+++ b/src/cantor_part.h
@@ -76,6 +76,8 @@ Q_SIGNALS:
     void requestNavigateToTocNode(QString nodeId);
     void requestChangeHierarchyLevel(QString hierarchyId, int levelDelta);
     void requestDeleteHierarchyEntry(QString hierarchyId, bool deleteContents);
+    void requestRenameCommandEntry(QString commandId, QString newTitle);
+    void requestDeleteCommandEntry(QString commandId);
     void requestRenamePlot(QString commandId, QString resultId, QString newTitle);
     void requestDeletePlot(QString commandId, QString resultId);
     void tocReadOnlyChanged(bool readOnly);
diff --git a/src/commandentry.cpp b/src/commandentry.cpp
index d0be704b..bf4e2115 100644
--- a/src/commandentry.cpp
+++ b/src/commandentry.cpp
@@ -427,6 +427,16 @@ void CommandEntry::regenerateCommandId()
     m_commandId = QUuid::createUuid().toString(QUuid::WithoutBraces);
 }
 
+QString CommandEntry::displayName() const
+{
+    return m_displayName;
+}
+
+void CommandEntry::setDisplayName(const QString& name)
+{
+    m_displayName = name.trimmed();
+}
+
 int CommandEntry::resultItemCount() const
 {
     return m_resultItems.size();
@@ -457,6 +467,37 @@ ResultItem* CommandEntry::resultItemById(const QString& resultId) const
     return nullptr;
 }
 
+void CommandEntry::cachePlotResultMetadata()
+{
+    m_plotResultMetadataToRestore.clear();
+
+    if (!m_expression)
+        return;
+
+    for (auto* result : m_expression->results())
+    {
+        if (!result || result->role() != Cantor::Result::Role::Plot)
+            continue;
+
+        m_plotResultMetadataToRestore.append({result->resultId(), result->displayName()});
+    }
+}
+
+void CommandEntry::restorePlotResultMetadata(Cantor::Result* result, int plotIndex)
+{
+    if (!result || result->role() != Cantor::Result::Role::Plot)
+        return;
+
+    if (plotIndex < 0 || plotIndex >= m_plotResultMetadataToRestore.size())
+        return;
+
+    const PlotResultMetadata& metadata = m_plotResultMetadataToRestore.at(plotIndex);
+    if (!metadata.first.isEmpty())
+        result->setResultId(metadata.first);
+    if (!metadata.second.isEmpty())
+        result->setDisplayName(metadata.second);
+}
+
 void CommandEntry::setExpression(Cantor::Expression* expr)
 {
     /*
@@ -536,6 +577,8 @@ void CommandEntry::setContent(const QDomElement& content, const KZip& file)
     if (!storedCommandId.isEmpty())
         m_commandId = storedCommandId;
 
+    m_displayName = content.attribute(QLatin1String("command-title")).trimmed();
+
     LoadedExpression* expr = new LoadedExpression( worksheet()->session() );
     expr->loadFromXml(content, file);
 
@@ -598,6 +641,12 @@ void CommandEntry::setContentFromJupyter(const QJsonObject& cell)
     if (!storedCommandId.isEmpty())
         m_commandId = storedCommandId;
 
+    const QJsonValue storedTitle = cantorMetadata.value(QLatin1String("command-title"));
+    if (storedTitle.isString())
+        m_displayName = storedTitle.toString().trimmed();
+    else
+        m_displayName.clear();
+
     LoadedExpression* expr=new LoadedExpression( worksheet()->session() );
     expr->loadFromJupyter(cell);
     setExpression(expr);
@@ -640,6 +689,10 @@ QJsonValue CommandEntry::toJupyterJson()
 
     QJsonObject cantorMetadata = metadata.value(Cantor::JupyterUtils::cantorMetadataKey).toObject();
     cantorMetadata.insert(QLatin1String("command-id"), m_commandId);
+    if (m_displayName.isEmpty())
+        cantorMetadata.remove(QLatin1String("command-title"));
+    else
+        cantorMetadata.insert(QLatin1String("command-title"), m_displayName);
     metadata.insert(Cantor::JupyterUtils::cantorMetadataKey, cantorMetadata);
 
     entry.insert(QLatin1String("metadata"), metadata);
@@ -699,6 +752,8 @@ QDomElement CommandEntry::toXml(QDomDocument& doc, KZip* archive)
 {
     QDomElement exprElem = doc.createElement( QLatin1String("Expression") );
     exprElem.setAttribute(QLatin1String("command-id"), m_commandId);
+    if (!m_displayName.isEmpty())
+        exprElem.setAttribute(QLatin1String("command-title"), m_displayName);
     QDomElement cmdElem = doc.createElement( QLatin1String("Command") );
     cmdElem.appendChild(doc.createTextNode( command() ));
     exprElem.appendChild(cmdElem);
@@ -839,6 +894,7 @@ bool CommandEntry::evaluate(EvaluationOption evalOp)
         auto* expr = worksheet()->session()->evaluateExpression(cmd);
         connect(expr, &Cantor::Expression::gotResult, this, [=]() { worksheet()->gotResult(expr); });
 
+        cachePlotResultMetadata();
         setExpression(expr);
 
         return true;
@@ -879,9 +935,21 @@ void CommandEntry::updateEntry()
             expandResults();
 
         bool addedResultItem = false;
+        int plotIndex = 0;
+        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)
+                ++plotIndex;
+        }
+
         for (int i = m_resultItems.size(); i < expr->results().size(); i++)
         {
-            if (auto* resultItem = ResultItem::create(this, expr->results()[i]))
+            auto* result = expr->results()[i];
+            if (result && result->role() == Cantor::Result::Role::Plot)
+                restorePlotResultMetadata(result, plotIndex++);
+
+            if (auto* resultItem = ResultItem::create(this, result))
             {
                 m_resultItems << resultItem;
                 addedResultItem = true;
diff --git a/src/commandentry.h b/src/commandentry.h
index 5da8fd6e..98a93038 100644
--- a/src/commandentry.h
+++ b/src/commandentry.h
@@ -9,6 +9,7 @@
 #define COMMANDENTRY_H
 
 #include <QPointer>
+#include <QPair>
 #include <QTimer>
 
 #include "worksheetentry.h"
@@ -41,6 +42,8 @@ class CommandEntry : public WorksheetEntry
     QString command();
     const QString& commandId() const;
     void regenerateCommandId();
+    QString displayName() const;
+    void setDisplayName(const QString& name);
     void setExpression(Cantor::Expression*);
     Cantor::Expression* expression();
 
@@ -122,6 +125,10 @@ class CommandEntry : public WorksheetEntry
     QPoint toGlobalPosition(QPointF);
     void initMenus();
 
+    using PlotResultMetadata = QPair<QString, QString>;
+
+    void cachePlotResultMetadata();
+    void restorePlotResultMetadata(Cantor::Result* result, int plotIndex);
 
     enum CompletionMode {PreliminaryCompletion, FinalCompletion};
     static const double VerticalSpacing;
@@ -134,6 +141,8 @@ class CommandEntry : public WorksheetEntry
     WorksheetTextItem* m_errorItem;
     QList<WorksheetTextItem*> m_informationItems;
     Cantor::Expression* m_expression;
+    QVector<PlotResultMetadata> m_plotResultMetadataToRestore;
+    QString m_displayName;
 
     DynamicHighlighter* m_dynamicHighlighter;
     bool m_variableHighlightingEnabled = true;
diff --git a/src/panelplugins/tocpanel/tocpanelplugin.cpp b/src/panelplugins/tocpanel/tocpanelplugin.cpp
index a04937f9..4f96fa7c 100644
--- a/src/panelplugins/tocpanel/tocpanelplugin.cpp
+++ b/src/panelplugins/tocpanel/tocpanelplugin.cpp
@@ -74,13 +74,13 @@ public:
         }
 
         const QString nodeType = index.data(m_nodeTypeRole).toString();
-        const bool isPlotNode = nodeType == TocNodeTypePlot;
+        const bool usesCustomTitle = nodeType == TocNodeTypePlot || nodeType == TocNodeTypeCommand;
         const QString title = index.data(m_nameRole).toString();
         const QString customTitle = index.data(m_customTitleRole).toString();
-        const QString editTitle = isPlotNode && !customTitle.isEmpty() ? customTitle : title;
+        const QString editTitle = usesCustomTitle && !customTitle.isEmpty() ? customTitle : title;
 
-        lineEdit->setText(isPlotNode ? editTitle : title);
-        if (isPlotNode)
+        lineEdit->setText(usesCustomTitle ? editTitle : title);
+        if (usesCustomTitle)
             lineEdit->setPlaceholderText(title);
 
         lineEdit->setProperty("tocNodeId", index.data(m_nodeIdRole));
@@ -88,7 +88,7 @@ public:
         lineEdit->setProperty("tocHierarchyId", index.data(m_hierarchyIdRole));
         lineEdit->setProperty("tocCommandId", index.data(m_entryIdRole));
         lineEdit->setProperty("tocResultId", index.data(m_resultIdRole));
-        lineEdit->setProperty("tocOriginalTitle", isPlotNode ? editTitle : title);
+        lineEdit->setProperty("tocOriginalTitle", usesCustomTitle ? editTitle : title);
         lineEdit->selectAll();
     }
 
@@ -141,6 +141,8 @@ void TableOfContentPanelPlugin::connectToShell(QObject* cantorShell)
     connect(cantorShell, SIGNAL(currentTocNodeChanged(QString)), this, SLOT(handleCurrentTocNodeChanged(QString)));
     connect(this, SIGNAL(requestChangeHierarchyLevel(QString,int)), cantorShell, SIGNAL(requestChangeHierarchyLevel(QString,int)));
     connect(this, SIGNAL(requestDeleteHierarchyEntry(QString,bool)), cantorShell, SIGNAL(requestDeleteHierarchyEntry(QString,bool)));
+    connect(this, SIGNAL(requestRenameCommandEntry(QString,QString)), cantorShell, SIGNAL(requestRenameCommandEntry(QString,QString)));
+    connect(this, SIGNAL(requestDeleteCommandEntry(QString)), cantorShell, SIGNAL(requestDeleteCommandEntry(QString)));
     connect(this, SIGNAL(requestRenamePlot(QString,QString,QString)), cantorShell, SIGNAL(requestRenamePlot(QString,QString,QString)));
     connect(this, SIGNAL(requestDeletePlot(QString,QString)), cantorShell, SIGNAL(requestDeletePlot(QString,QString)));
     connect(cantorShell, SIGNAL(tocReadOnlyChanged(bool)), this, SLOT(handleReadOnlyChanged(bool)));
@@ -523,11 +525,15 @@ void TableOfContentPanelPlugin::beginRename(const QModelIndex& index)
     const QString hierarchyId = index.data(HierarchyIdRole).toString();
     const QString nodeType = index.data(NodeTypeRole).toString();
     const bool isPlotNode = nodeType == TocNodeTypePlot;
+    const bool isCommandNode = nodeType == TocNodeTypeCommand;
 
     if (nodeId.isEmpty() || !index.data(EditableRole).toBool())
         return;
 
-    if (!isPlotNode && hierarchyId.isEmpty())
+    if (!isPlotNode && !isCommandNode && hierarchyId.isEmpty())
+        return;
+
+    if (isCommandNode && index.data(EntryIdRole).toString().isEmpty())
         return;
 
     if (isPlotNode && (index.data(EntryIdRole).toString().isEmpty() || index.data(ResultIdRole).toString().isEmpty()))
@@ -589,7 +595,8 @@ void TableOfContentPanelPlugin::handleEditorCommit(QWidget* editor)
     if (position >= 0 && position < m_nodes.size())
     {
         const TocNode& node = m_nodes.at(position);
-        oldName = nodeType == TocNodeTypePlot && !node.customTitle.isEmpty() ? node.customTitle : node.title;
+        const bool usesCustomTitle = nodeType == TocNodeTypePlot || nodeType == TocNodeTypeCommand;
+        oldName = usesCustomTitle && !node.customTitle.isEmpty() ? node.customTitle : node.title;
     }
 
     if (newName == oldName)
@@ -603,6 +610,13 @@ void TableOfContentPanelPlugin::handleEditorCommit(QWidget* editor)
         m_pendingRenameCommandId = commandId;
         m_pendingRenameResultId = resultId;
     }
+    else if (nodeType == TocNodeTypeCommand)
+    {
+        if (commandId.isEmpty())
+            return;
+
+        m_pendingRenameCommandId = commandId;
+    }
     else
     {
         if (hierarchyId.isEmpty())
@@ -655,6 +669,8 @@ void TableOfContentPanelPlugin::finishEditorSession()
 
         if (nodeType == TocNodeTypePlot)
             Q_EMIT requestRenamePlot(commandId, resultId, title);
+        else if (nodeType == TocNodeTypeCommand)
+            Q_EMIT requestRenameCommandEntry(commandId, title);
         else
             Q_EMIT requestRenameHierarchyEntry(hierarchyId, title);
     }
@@ -899,6 +915,8 @@ void TableOfContentPanelPlugin::handleContextMenuRequested(const QPoint& positio
         }
         else if (nodeType == TocNodeTypeCommand)
         {
+            const QString commandId = index.data(EntryIdRole).toString();
+            const QString displayText = index.data(DisplayTextRole).toString();
             QAction* goToCommandAction = menu.addAction(i18n("Go to Command"));
             goToCommandAction->setEnabled(!nodeId.isEmpty() && index.data(NavigableRole).toBool());
             connect(goToCommandAction, &QAction::triggered, this, [this, nodeId]()
@@ -906,6 +924,37 @@ void TableOfContentPanelPlugin::handleContextMenuRequested(const QPoint& positio
                 if (!nodeId.isEmpty())
                     Q_EMIT requestNavigateToTocNode(nodeId);
             });
+
+            menu.addSeparator();
+
+            QAction* renameAction = menu.addAction(QIcon::fromTheme(QStringLiteral("edit-rename")), i18n("Rename Command"));
+            renameAction->setEnabled(!m_readOnly && !nodeId.isEmpty() && !commandId.isEmpty());
+            connect(renameAction, &QAction::triggered, this, [this, nodeId]()
+            {
+                if (auto* item = m_itemsByNodeId.value(nodeId, nullptr))
+                    beginRename(item->index());
+            });
+
+            QAction* deleteAction = menu.addAction(QIcon::fromTheme(QStringLiteral("edit-delete")), i18n("Delete Command"));
+            deleteAction->setEnabled(!m_readOnly && !commandId.isEmpty());
+            connect(deleteAction, &QAction::triggered, this, [this, commandId, displayText]()
+            {
+                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);
+            });
         }
         else if (nodeType == TocNodeTypePlot)
         {
diff --git a/src/panelplugins/tocpanel/tocpanelplugin.h b/src/panelplugins/tocpanel/tocpanelplugin.h
index 3a26c9b0..6acc0335 100644
--- a/src/panelplugins/tocpanel/tocpanelplugin.h
+++ b/src/panelplugins/tocpanel/tocpanelplugin.h
@@ -46,6 +46,8 @@ class TableOfContentPanelPlugin : public Cantor::PanelPlugin
     void requestRenameHierarchyEntry(QString hierarchyId, QString newName);
     void requestChangeHierarchyLevel(QString hierarchyId, int levelDelta);
     void requestDeleteHierarchyEntry(QString hierarchyId, bool deleteContents);
+    void requestRenameCommandEntry(QString commandId, QString newTitle);
+    void requestDeleteCommandEntry(QString commandId);
     void requestRenamePlot(QString commandId, QString resultId, QString newTitle);
     void requestDeletePlot(QString commandId, QString resultId);
 
diff --git a/src/worksheet.cpp b/src/worksheet.cpp
index d8f566a3..efbd9cf2 100644
--- a/src/worksheet.cpp
+++ b/src/worksheet.cpp
@@ -3131,6 +3131,16 @@ void Worksheet::updateCurrentTocNodeFromResult(CommandEntry* commandEntry, Canto
     m_hierarchyManager->updateCurrentTocNodeFromResult(commandEntry, result);
 }
 
+void Worksheet::renameCommandEntry(const QString& commandId, const QString& newTitle)
+{
+    m_hierarchyManager->renameCommandEntry(commandId, newTitle);
+}
+
+void Worksheet::deleteCommandEntry(const QString& commandId)
+{
+    m_hierarchyManager->deleteCommandEntry(commandId);
+}
+
 void Worksheet::renamePlot(const QString& commandId, const QString& resultId, const QString& newTitle)
 {
     m_hierarchyManager->renamePlot(commandId, resultId, newTitle);
diff --git a/src/worksheet.h b/src/worksheet.h
index 262f90d1..7f6bed38 100644
--- a/src/worksheet.h
+++ b/src/worksheet.h
@@ -277,6 +277,8 @@ class Worksheet : public QGraphicsScene
     void renameHierarchyEntry(const QString& hierarchyId, const QString& newName);
     void changeHierarchyLevel(QString hierarchyId, int levelDelta);
     void deleteHierarchyEntry(const QString& hierarchyId, bool deleteContents);
+    void renameCommandEntry(const QString& commandId, const QString& newTitle);
+    void deleteCommandEntry(const QString& commandId);
     void renamePlot(const QString& commandId, const QString& resultId, const QString& newTitle);
     void deletePlot(const QString& commandId, const QString& resultId);
     void navigateToTocNode(QString nodeId);
diff --git a/src/worksheetentry.cpp b/src/worksheetentry.cpp
index cac97243..cee2c97b 100644
--- a/src/worksheetentry.cpp
+++ b/src/worksheetentry.cpp
@@ -331,15 +331,31 @@ bool WorksheetEntry::focusEntry(int pos, qreal xCoord)
 void WorksheetEntry::moveToPreviousEntry(int pos, qreal x)
 {
     WorksheetEntry* entry = previous();
-    while (entry && !(entry->wantFocus() && entry->focusEntry(pos, x)))
+    while (entry)
+    {
+        if (entry->wantFocus() && entry->focusEntry(pos, x))
+        {
+            worksheet()->makeVisible(entry);
+            return;
+        }
+
         entry = entry->previous();
+    }
 }
 
 void WorksheetEntry::moveToNextEntry(int pos, qreal x)
 {
     WorksheetEntry* entry = next();
-    while (entry && !(entry->wantFocus() && entry->focusEntry(pos, x)))
+    while (entry)
+    {
+        if (entry->wantFocus() && entry->focusEntry(pos, x))
+        {
+            worksheet()->makeVisible(entry);
+            return;
+        }
+
         entry = entry->next();
+    }
 }
 
 Worksheet* WorksheetEntry::worksheet()
diff --git a/src/worksheethierarchymanager.cpp b/src/worksheethierarchymanager.cpp
index c97edc87..3d0c272e 100644
--- a/src/worksheethierarchymanager.cpp
+++ b/src/worksheethierarchymanager.cpp
@@ -154,11 +154,12 @@ QVariantList WorksheetHierarchyManager::collectTocNodes() const
             commandNode.insert(QStringLiteral("id"), commandNodeId);
             commandNode.insert(QStringLiteral("parentId"), parentNodeId);
             commandNode.insert(QStringLiteral("type"), QString(TocNodeTypeCommand));
-            commandNode.insert(QStringLiteral("title"), commandTocTitle());
+            commandNode.insert(QStringLiteral("title"), commandTocTitle(commandEntry));
+            commandNode.insert(QStringLiteral("customTitle"), commandEntry->displayName());
             commandNode.insert(QStringLiteral("displayText"), commandTocDisplayText(commandEntry));
             commandNode.insert(QStringLiteral("hierarchyText"), QString());
             commandNode.insert(QStringLiteral("depth"), hierarchyDepths.isEmpty() ? 0 : hierarchyDepths.last() + 1);
-            commandNode.insert(QStringLiteral("editable"), false);
+            commandNode.insert(QStringLiteral("editable"), true);
             commandNode.insert(QStringLiteral("navigable"), true);
             commandNode.insert(QStringLiteral("hierarchyId"), QString());
             commandNode.insert(QStringLiteral("resultIndex"), -1);
@@ -264,15 +265,18 @@ bool WorksheetHierarchyManager::parsePlotNodeId(const QString& nodeId, QString*
     return true;
 }
 
-QString WorksheetHierarchyManager::commandTocTitle() const
+QString WorksheetHierarchyManager::commandTocTitle(CommandEntry* entry) const
 {
+    if (entry && !entry->displayName().isEmpty())
+        return entry->displayName();
+
     return i18n("Command");
 }
 
 QString WorksheetHierarchyManager::commandTocDisplayText(CommandEntry* entry) const
 {
     auto* expression = entry ? entry->expression() : nullptr;
-    const QString title = commandTocTitle();
+    const QString title = commandTocTitle(entry);
     if (m_worksheet->m_showExpressionIds && expression && expression->id() != -1)
         return i18n("%1 %2", title, expression->id());
 
@@ -842,6 +846,50 @@ void WorksheetHierarchyManager::updateCurrentTocNodeFromResult(CommandEntry* com
         updateCurrentHierarchyFromEntry(commandEntry);
 }
 
+void WorksheetHierarchyManager::renameCommandEntry(const QString& commandId, const QString& newTitle)
+{
+    if (m_worksheet->m_readOnly || commandId.isEmpty())
+        return;
+
+    QString normalizedTitle = newTitle;
+    normalizedTitle.replace(QLatin1Char('\r'), QLatin1Char(' '));
+    normalizedTitle.replace(QLatin1Char('\n'), QLatin1Char(' '));
+    normalizedTitle = normalizedTitle.trimmed();
+
+    const CommandSearchResult commandSearch = findCommandEntryById(commandId);
+    auto* commandEntry = commandSearch.entry;
+
+    if (!commandEntry || commandEntry->displayName() == normalizedTitle)
+        return;
+
+    commandEntry->setDisplayName(normalizedTitle);
+    m_worksheet->setModified();
+    scheduleTocStructureRefresh();
+}
+
+void WorksheetHierarchyManager::deleteCommandEntry(const QString& commandId)
+{
+    if (m_worksheet->m_readOnly || commandId.isEmpty())
+        return;
+
+    const CommandSearchResult commandSearch = findCommandEntryById(commandId);
+    auto* commandEntry = commandSearch.entry;
+
+    if (!commandEntry)
+        return;
+
+    const bool expanded = expandHierarchyAncestors(commandSearch.collapsedAncestors);
+    if (expanded)
+    {
+        updateHierarchyLayout();
+        m_worksheet->updateLayout();
+    }
+
+    m_worksheet->clearAllSelections();
+    m_worksheet->notifyEntryFocus(nullptr);
+    commandEntry->startRemoving(false);
+}
+
 void WorksheetHierarchyManager::renamePlot(const QString& commandId, const QString& resultId, const QString& newTitle)
 {
     if (m_worksheet->m_readOnly || commandId.isEmpty() || resultId.isEmpty())
diff --git a/src/worksheethierarchymanager.h b/src/worksheethierarchymanager.h
index 38a5db2a..9dca2eba 100644
--- a/src/worksheethierarchymanager.h
+++ b/src/worksheethierarchymanager.h
@@ -19,12 +19,6 @@ namespace Cantor {
     class Result;
 }
 
-/*
- * Helper owned by Worksheet for managing the structure shown in the
- * Table of Contents panel. This includes hierarchy entries, command
- * entries, plot result nodes, navigation, and structure-related editing
- * actions.
- */
 class WorksheetHierarchyManager : public QObject
 {
   public:
@@ -47,6 +41,8 @@ class WorksheetHierarchyManager : public QObject
     void renameHierarchyEntry(const QString& hierarchyId, const QString& newName);
     void changeHierarchyLevel(const QString& hierarchyId, int levelDelta);
     void deleteHierarchyEntry(const QString& hierarchyId, bool deleteContents);
+    void renameCommandEntry(const QString& commandId, const QString& newTitle);
+    void deleteCommandEntry(const QString& commandId);
     void renamePlot(const QString& commandId, const QString& resultId, const QString& newTitle);
     void deletePlot(const QString& commandId, const QString& resultId);
     void navigateToTocNode(QString nodeId);
@@ -93,7 +89,7 @@ class WorksheetHierarchyManager : public QObject
     bool navigateToPlotResult(CommandEntry* commandEntry, const QString& resultId);
     void setCurrentTocNode(const QString& nodeId);
     QString hierarchyIdForEntry(WorksheetEntry* entry) const;
-    QString commandTocTitle() const;
+    QString commandTocTitle(CommandEntry* entry) const;
     QString commandTocDisplayText(CommandEntry* entry) const;
     QString plotTocTitle(Cantor::Result* result) const;
     QString plotTocDisplayText(CommandEntry* entry, Cantor::Result* result, int plotOrdinal, int plotCount) const;
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.