[education/rkward] rkward: Add code navigation actions to context menu

Thomas Friedrichsmeier null at kde.org
Mon May 26 15:06:38 BST 2025


Git commit 839dbff32f0d38e14bfe7db27751dccad729ec64 by Thomas Friedrichsmeier.
Committed on 26/05/2025 at 14:06.
Pushed by tfry into branch 'master'.

Add code navigation actions to context menu

M  +1    -1    rkward/autotests/data/script1.Rmd
M  +187  -117  rkward/windows/rkcodenavigation.cpp
M  +5    -1    rkward/windows/rkcodenavigation.h
M  +3    -9    rkward/windows/rkcommandeditorwindow.cpp
M  +1    -2    rkward/windows/rkcommandeditorwindow.h
M  +3    -2    rkward/windows/rkcommandeditorwindowpart.rc

https://invent.kde.org/education/rkward/-/commit/839dbff32f0d38e14bfe7db27751dccad729ec64

diff --git a/rkward/autotests/data/script1.Rmd b/rkward/autotests/data/script1.Rmd
index 70540faf2..6f8a981de 100644
--- a/rkward/autotests/data/script1.Rmd
+++ b/rkward/autotests/data/script1.Rmd
@@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 
 This is markdown ``{r} .some <- `markdown` `` and this is too, with some `inline(code)`.
 
-Usually, there would be lot of mardown (and this may contain many relevant markers, such as {}, and []).
+Usually, there would be lot of markdown (and this may contain many relevant markers, such as {}, and []).
 
 ```{r echo=TRUE}
 symb01 <-symb02()
diff --git a/rkward/windows/rkcodenavigation.cpp b/rkward/windows/rkcodenavigation.cpp
index 5379db689..639503e93 100644
--- a/rkward/windows/rkcodenavigation.cpp
+++ b/rkward/windows/rkcodenavigation.cpp
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 #include <QFrame>
 #include <QKeyEvent>
 #include <QLabel>
+#include <QMenu>
 #include <QPainter>
 #include <QVBoxLayout>
 
@@ -25,119 +26,37 @@ SPDX-License-Identifier: GPL-2.0-or-later
 
 #include "../debug.h"
 
-class RKCodeNavigationWidget : public QFrame {
+class RKCodeNavigationBase {
   public:
-	RKCodeNavigationWidget(KTextEditor::View *view, QWidget *parent) : QFrame(parent, Qt::Popup | Qt::FramelessWindowHint | Qt::BypassWindowManagerHint), view(view), doc(view->document()) {
-		RK_TRACE(APP);
-
-		auto scheme = RKStyle::viewScheme();
-		auto pal = palette();
-		pal.setColor(backgroundRole(), scheme->background(KColorScheme::PositiveBackground).color());
-		setPalette(pal);
-
-		view->installEventFilter(this);
-		if (view->window()) view->window()->installEventFilter(this);
-		connect(doc, &KTextEditor::Document::textChanged, this, &RKCodeNavigationWidget::deleteLater);
-
-		auto box = new QVBoxLayout(this);
-		auto label = new QLabel(i18n("<b>Code Navigation</b> (<a href=\"rkward://page/rkward_code_navigation\">Help</a>)"));
-		QObject::connect(label, &QLabel::linkActivated, RKWorkplace::mainWorkplace(), &RKWorkplace::openAnyUrlString);
-		box->addWidget(label);
-
-		input = new KSqueezedTextLabel();
-		input->setFocusPolicy(Qt::StrongFocus);
-		input->installEventFilter(this);
-		box->addWidget(input);
-
-		message = new QLabel();
-		message->setTextFormat(Qt::RichText);
-		message->hide();
-		box->addWidget(message);
-
-		rmdmode = doc->highlightingMode() == u"R Markdown"_s;
-		ps = RKParsedScript(doc->text(), rmdmode);
-		StoredPosition initial;
-		// translate cursor position to string index
-		initial.pos = cursorToPosition(view->cursorPosition());
-		initial.selection = view->selectionRange();
-		stored_positions.append(initial);
-		stored_size = size();
-		updateLabel();
-	}
-
-	void updatePos() {
-		RK_TRACE(APP);
-		move(view->mapToGlobal(view->geometry().topRight() - QPoint(width() + 5, -5)));
-	}
-
-	void focusOutEvent(QFocusEvent *) override {
-		RK_TRACE(APP);
-		deleteLater();
-	}
-
-	int cursorToPosition(const KTextEditor::Cursor &cursor) {
-		RK_TRACE(APP);
-		int pos = cursor.column();
-		for (int l = 0; l < cursor.line(); ++l) {
-			pos += doc->lineLength(l) + 1;
-		}
-		return pos;
-	}
-
-	KTextEditor::Cursor positionToCursor(int pos) {
-		RK_TRACE(APP);
-		for (int l = 0; l < doc->lines(); ++l) {
-			pos -= (doc->lineLength(l) + 1);
-			if (pos < 0) {
-				return KTextEditor::Cursor(l, pos + doc->lineLength(l) + 1);
-			}
-		}
-		return KTextEditor::Cursor();
-	}
+	RKCodeNavigationBase(KTextEditor::View *view) :
+		view(view),
+		doc(view->document()),
+		rmdmode(doc->highlightingMode() == u"R Markdown"_s),
+		ps(doc->text(), rmdmode) {
+	};
 
 	struct StoredPosition {
-		QString query;
 		int pos;
 		KTextEditor::Range selection;
-		bool hit_top;
-		bool hit_bottom;
 		QChar command;
+		QString message;
 	};
 
-	void navigate(const StoredPosition &newpos) {
-		RK_TRACE(APP);
-		RK_DEBUG(COMMANDEDITOR, DL_DEBUG, "navigate to %d", newpos.pos);
-		// translate final position back to cursor coordinates
-		if (!newpos.selection.isEmpty()) {
-			view->setSelection(newpos.selection);
-			view->setCursorPosition(newpos.selection.start());
-		} else {
-			if (message->isVisible()) {
-				message->hide();
-				QTimer::singleShot(0, this, [this]() {
-					resize(stored_size);
-					updatePos();
-				});
-			}
-			view->setCursorPosition(positionToCursor(newpos.pos));
-			view->setSelection(KTextEditor::Range(-1, -1, -1, -1));
-		}
-
-		updateLabel();
+	StoredPosition viewPosition() const {
+		StoredPosition pos;
+		// translate cursor position to string index
+		pos.pos = cursorToPosition(view->cursorPosition());
+		pos.selection = view->selectionRange();
+		return pos;
 	}
 
-	void handleCommand(const QChar command) {
-		RK_TRACE(APP);
-		// start from last known position
-		auto last = stored_positions.last();
+	StoredPosition handleCommandBase(const QChar &command) const {
+		auto last = viewPosition();
 		auto pos = last.pos;
 
 		// apply navigation command
 		StoredPosition newpos;
 		newpos.pos = pos;
-		newpos.query = stored_positions.last().query + command;
-		newpos.hit_bottom = false;
-		newpos.hit_top = false;
 		newpos.command = command;
 
 		auto ci = ps.contextAtPos(pos);
@@ -171,43 +90,146 @@ class RKCodeNavigationWidget : public QFrame {
 			newpos.selection = KTextEditor::Range(positionToCursor(posa), positionToCursor(posb + 1));
 		} else if (command == u'S') {
 			if (!rmdmode) {
-				message->setText(i18n("Command 'S' is for R Markdown, only"));
-				message->show();
-				updatePos();
-				return;
+				newpos.message = i18n("Command 'S' is for R Markdown, only");
+				return newpos;
 			}
 			auto posa = ps.getContext(ps.firstContextInChunk(ci)).start;
 			auto posb = ps.lastPositionInChunk(ci);
 			newpos.selection = KTextEditor::Range(positionToCursor(posa), positionToCursor(posb + 1));
 		} else {
 			RK_DEBUG(COMMANDEDITOR, DL_WARNING, "unknown navigation commmand");
-			message->setText(i18n("Unknown command <tt><b>%1</b></tt>").arg(command));
-			message->show();
-			updatePos();
-			return;
+			newpos.message = i18n("Unknown command <tt><b>%1</b></tt>").arg(command);
+			return newpos;
 		}
 
 		if (newpos.pos < 0 && command.toLower() != u's') {
 			if (rmdmode && newpos.command.toLower() != u'c') {
 				if (command.toLower() == command) {
-					message->setText(i18nc("Keep this short", "Search hit bottom.<br/><tt><b>c</b></tt> to move to next chunk."));
+					newpos.message = i18nc("Keep this short", "Search hit bottom.<br/><tt><b>c</b></tt> to move to next chunk.");
 				} else {
-					message->setText(i18nc("Keep this short", "Search hit top.<br/><tt><b>C</b></tt> to move to previous chunk."));
+					newpos.message = i18nc("Keep this short", "Search hit top.<br/><tt><b>C</b></tt> to move to previous chunk.");
 				}
 			} else {
 				if (command.toLower() == command) {
-					message->setText(i18nc("Keep this short", "Search hit bottom.<br/><tt><b>1</b></tt> to move to top."));
+					newpos.message = i18nc("Keep this short", "Search hit bottom.<br/><tt><b>1</b></tt> to move to top.");
 				} else {
-					message->setText(i18nc("Keep this short", "Search hit top.<br/><tt><b>!</b></tt> to move to bottom."));
+					newpos.message = i18nc("Keep this short", "Search hit top.<br/><tt><b>!</b></tt> to move to bottom.");
 				}
 			}
+		}
+
+		return newpos;
+	}
+
+	void navigateBase(const StoredPosition &newpos) const {
+		RK_TRACE(APP);
+		RK_DEBUG(COMMANDEDITOR, DL_DEBUG, "navigate to %d", newpos.pos);
+		// translate final position back to cursor coordinates
+		if (!newpos.selection.isEmpty()) {
+			view->setSelection(newpos.selection);
+			view->setCursorPosition(newpos.selection.start());
+		} else {
+			view->setCursorPosition(positionToCursor(newpos.pos));
+			view->setSelection(KTextEditor::Range(-1, -1, -1, -1));
+		}
+	}
+
+	int cursorToPosition(const KTextEditor::Cursor &cursor) const {
+		RK_TRACE(APP);
+		int pos = cursor.column();
+		for (int l = 0; l < cursor.line(); ++l) {
+			pos += doc->lineLength(l) + 1;
+		}
+		return pos;
+	}
+
+	KTextEditor::Cursor positionToCursor(int pos) const {
+		RK_TRACE(APP);
+		for (int l = 0; l < doc->lines(); ++l) {
+			pos -= (doc->lineLength(l) + 1);
+			if (pos < 0) {
+				return KTextEditor::Cursor(l, pos + doc->lineLength(l) + 1);
+			}
+		}
+		return KTextEditor::Cursor();
+	}
+
+  protected:
+	KTextEditor::View *view;
+	KTextEditor::Document *doc;
+	bool rmdmode;
+	RKParsedScript ps;
+};
+
+class RKCodeNavigationWidget : public QFrame, public RKCodeNavigationBase {
+  public:
+	RKCodeNavigationWidget(KTextEditor::View *view, QWidget *parent) : QFrame(parent, Qt::Popup | Qt::FramelessWindowHint | Qt::BypassWindowManagerHint), RKCodeNavigationBase(view) {
+		RK_TRACE(APP);
+
+		auto scheme = RKStyle::viewScheme();
+		auto pal = palette();
+		pal.setColor(backgroundRole(), scheme->background(KColorScheme::PositiveBackground).color());
+		setPalette(pal);
+
+		view->installEventFilter(this);
+		if (view->window()) view->window()->installEventFilter(this);
+		connect(doc, &KTextEditor::Document::textChanged, this, &RKCodeNavigationWidget::deleteLater);
+
+		auto box = new QVBoxLayout(this);
+		auto label = new QLabel(i18n("<b>Code Navigation</b> (<a href=\"rkward://page/rkward_code_navigation\">Help</a>)"));
+		QObject::connect(label, &QLabel::linkActivated, RKWorkplace::mainWorkplace(), &RKWorkplace::openAnyUrlString);
+		box->addWidget(label);
+
+		input = new KSqueezedTextLabel();
+		input->setFocusPolicy(Qt::StrongFocus);
+		input->installEventFilter(this);
+		box->addWidget(input);
+
+		message = new QLabel();
+		message->setTextFormat(Qt::RichText);
+		message->hide();
+		box->addWidget(message);
+
+		stored_size = size();
+		stored_positions.append(viewPosition());
+		updateLabel();
+	}
+
+	void updatePos() {
+		RK_TRACE(APP);
+		move(view->mapToGlobal(view->geometry().topRight() - QPoint(width() + 5, -5)));
+	}
+
+	void focusOutEvent(QFocusEvent *) override {
+		RK_TRACE(APP);
+		deleteLater();
+	}
+
+	void navigate(const StoredPosition &newpos) {
+		RK_TRACE(APP);
+		navigateBase(newpos);
+		if (message->isVisible()) {
+			message->hide();
+			QTimer::singleShot(0, this, [this]() {
+				resize(stored_size);
+				updatePos();
+			});
+		}
+		updateLabel();
+	}
+
+	void handleCommand(const QChar command) {
+		RK_TRACE(APP);
+
+		auto pos = handleCommandBase(command);
+		if (!pos.message.isEmpty()) {
 			message->show();
 			updatePos();
 			return;
 		}
 
-		stored_positions.append(newpos);
-		navigate(newpos);
+		stored_positions.append(pos);
+		navigate(pos);
 	}
 
 	bool eventFilter(QObject *from, QEvent *event) override {
@@ -258,14 +280,10 @@ class RKCodeNavigationWidget : public QFrame {
 		paint.drawRect(0, 0, width() - 1, height() - 1);
 	}
 
-	KTextEditor::View *view;
-	KTextEditor::Document *doc;
 	KSqueezedTextLabel *input;
-	QList<StoredPosition> stored_positions;
-	RKParsedScript ps;
 	QLabel *message;
 	QSize stored_size;
-	bool rmdmode;
+	QList<StoredPosition> stored_positions;
 };
 
 namespace RKCodeNavigation {
@@ -275,4 +293,56 @@ namespace RKCodeNavigation {
 		w->updatePos();
 		w->input->setFocus();
 	}
+
+	void singleShotNavigation(KTextEditor::View *view, const QChar &command) {
+		RKCodeNavigationBase w(view);
+		w.navigateBase(w.handleCommandBase(command));
+	}
+
+	QAction *directNavigationAction(const QString &label, KTextEditor::View *view, const QChar command) {
+		QAction *a = new QAction(label);
+		QObject::connect(a, &QAction::triggered, view, [view, command]() { singleShotNavigation(view, command); });
+		return a;
+	}
+
+	QMenu *actionMenu(KTextEditor::View *view, QWidget *parent) {
+		QMenu *menu = new QMenu(parent);
+		auto action = menu->addAction(i18n("Code Navigation Mode"));
+		action->setIcon(QIcon::fromTheme(u"debug-step-into"_s));
+		menu->menuAction()->setIcon(action->icon());
+		menu->menuAction()->setText(i18n("Code Navigation"));
+		QObject::connect(action, &QAction::triggered, parent, [view, parent]() { doNavigation(view, parent); });
+
+		menu->addSeparator();
+		QObject::connect(menu, &QMenu::aboutToShow, menu, [view, menu]() {
+			bool rmdmode = view->document()->highlightingMode() == u"R Markdown"_s;
+			// TODO: Allow setting and customizing shortcuts
+			menu->addAction(directNavigationAction(i18n("Next statement"), view, u'n'));
+			menu->addAction(directNavigationAction(i18n("Previous statement"), view, u'N'));
+			menu->addAction(directNavigationAction(i18n("Next (inner) statement"), view, u'i'));
+			menu->addAction(directNavigationAction(i18n("Previous (inner) statement"), view, u'P'));
+			menu->addAction(directNavigationAction(i18n("Next outer statement"), view, u'o'));
+			menu->addAction(directNavigationAction(i18n("Previous outer statement"), view, u'O'));
+			menu->addAction(directNavigationAction(i18n("Next toplevel statement"), view, u't'));
+			menu->addAction(directNavigationAction(i18n("Previous toplevel statement"), view, u'T'));
+			if (rmdmode) {
+				menu->addAction(directNavigationAction(i18n("Next code chunk"), view, u'c'));
+				menu->addAction(directNavigationAction(i18n("Previous code chunk"), view, u'C'));
+			}
+			menu->addSeparator();
+			menu->addAction(directNavigationAction(i18n("Select current statement"), view, u's'));
+			if (rmdmode) {
+				menu->addAction(directNavigationAction(i18n("Select current code chunk"), view, u'S'));
+			}
+		});
+
+		QObject::connect(menu, &QMenu::aboutToHide, menu, [menu]() {
+			const auto acts = menu->actions();
+			for (int i = acts.size() - 1; i >= 2; --i) {
+				acts[i]->deleteLater();
+			}
+		});
+
+		return menu;
+	}
 };
diff --git a/rkward/windows/rkcodenavigation.h b/rkward/windows/rkcodenavigation.h
index 3b93238c9..6c33ee1c6 100644
--- a/rkward/windows/rkcodenavigation.h
+++ b/rkward/windows/rkcodenavigation.h
@@ -10,9 +10,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
 
 #include <KTextEditor/View>
 
+class QMenu;
+
 namespace RKCodeNavigation {
 	void doNavigation(KTextEditor::View *view, QWidget *parent);
-	void addMenuEntries(QMenu *menu);
+/** creates a menu code navigation actions.
+ *  The first action in the menu brings up code navigation mode (may e.g. be plugged into a toolbar). */
+	QMenu *actionMenu(KTextEditor::View *view, QWidget *parent);
 };
 
 #endif
diff --git a/rkward/windows/rkcommandeditorwindow.cpp b/rkward/windows/rkcommandeditorwindow.cpp
index e7e12f55f..112fcd4cd 100644
--- a/rkward/windows/rkcommandeditorwindow.cpp
+++ b/rkward/windows/rkcommandeditorwindow.cpp
@@ -255,8 +255,6 @@ RKCommandEditorWindow::RKCommandEditorWindow(QWidget *parent, const QUrl &_url,
 	connect(m_doc, &KTextEditor::Document::modifiedChanged, this, &RKCommandEditorWindow::autoSaveHandlerModifiedChanged);
 	connect(m_doc, &KTextEditor::Document::textChanged, this, &RKCommandEditorWindow::textChanged);
 	connect(m_view, &KTextEditor::View::selectionChanged, this, &RKCommandEditorWindow::selectionChanged);
-	// somehow the katepart loses the context menu each time it loses focus
-	connect(m_view, &KTextEditor::View::focusIn, this, &RKCommandEditorWindow::focusIn);
 
 	if (use_r_highlighting) {
 		RKCommandHighlighter::setHighlighting(m_doc, RKCommandHighlighter::RScript);
@@ -405,8 +403,9 @@ void RKCommandEditorWindow::initializeActions(KActionCollection *ac) {
 	file_save_as_action = findAction(m_view, QStringLiteral("file_save_as"));
 	if (file_save_as_action) file_save_as_action->setText(i18n("Save Script As..."));
 
-	action = ac->addAction(QStringLiteral("code_navigation"), this, [this]() { RKCodeNavigation::doNavigation(m_view, this); });
-	action->setText(i18n("Code Navigation"));
+	QMenu *cnmenu = RKCodeNavigation::actionMenu(m_view, this);
+	ac->addAction(QStringLiteral("code_navigation_menu"), cnmenu->menuAction());
+	action = ac->addAction(QStringLiteral("code_navigation"), cnmenu->actions().first());
 	ac->setDefaultShortcuts(action, QList<QKeySequence>() << (Qt::MetaModifier | Qt::Key_N));
 
 	const auto actions = standardActionCollection()->actions() + ac->actions();
@@ -476,11 +475,6 @@ void RKCommandEditorWindow::initBlocks() {
 	RK_ASSERT(block_records.size() == NUM_BLOCK_RECORDS);
 }
 
-void RKCommandEditorWindow::focusIn(KTextEditor::View *v) {
-	RK_TRACE(COMMANDEDITOR);
-	RK_ASSERT(v == m_view);
-}
-
 QString RKCommandEditorWindow::fullCaption() {
 	RK_TRACE(COMMANDEDITOR);
 
diff --git a/rkward/windows/rkcommandeditorwindow.h b/rkward/windows/rkcommandeditorwindow.h
index 44b256a13..dcb0cdcd5 100644
--- a/rkward/windows/rkcommandeditorwindow.h
+++ b/rkward/windows/rkcommandeditorwindow.h
@@ -1,6 +1,6 @@
 /*
 rkcommandeditorwindow - This file is part of the RKWard project. Created: Mon Aug 30 2004
-SPDX-FileCopyrightText: 2004-2022 by Thomas Friedrichsmeier <thomas.friedrichsmeier at kdemail.net>
+SPDX-FileCopyrightText: 2004-2025 by Thomas Friedrichsmeier <thomas.friedrichsmeier at kdemail.net>
 SPDX-FileContributor: The RKWard Team <rkward-devel at kde.org>
 SPDX-License-Identifier: GPL-2.0-or-later
 */
@@ -108,7 +108,6 @@ class RKCommandEditorWindow : public RKMDIWindow, public RKScriptContextProvider
   public Q_SLOTS:
 	/** update Tab caption according to the current url. Display the filename-component of the URL, or - if not available - a more elaborate description of the url. Also appends a "[modified]" if appropriate */
 	void updateCaption();
-	void focusIn(KTextEditor::View *);
 	/** run the currently selected command(s) or line */
 	void runCurrent();
 	/** run the entire script */
diff --git a/rkward/windows/rkcommandeditorwindowpart.rc b/rkward/windows/rkcommandeditorwindowpart.rc
index c016e3038..35ff1ddf4 100644
--- a/rkward/windows/rkcommandeditorwindowpart.rc
+++ b/rkward/windows/rkcommandeditorwindowpart.rc
@@ -4,7 +4,7 @@ SPDX-FileCopyrightText: by Thomas Friedrichsmeier <thomas.friedrichsmeier at kdemai
 SPDX-FileContributor: The RKWard Team <rkward-devel at kde.org>
 SPDX-License-Identifier: GPL-2.0-or-later
 -->
-<kpartgui name="rkward_commandeditor" version="821">
+<kpartgui name="rkward_commandeditor" version="822">
 	<MenuBar>
 		<Menu name="file"><text>&File</text></Menu>  <!-- Define placeholder to work around menu ordering bug -->
 		<Menu name="edit"><text>&Edit</text>
@@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 			<Action name="render_preview" group="postrun_actions_merge"/>
 			<Separator group="postrun_actions_merge"/>
 			<Action name="setwd_to_script" group="postrun_actions_merge"/>
-			<Action name="code_navigation" group="postrun_actions_merge"/>
+			<Action name="code_navigation_menu" group="postrun_actions_merge"/>
 		</Menu>
 		<Menu name="settings"><text>&Settings</text>
 			<Action name="configure_commandeditor"></Action>
@@ -35,6 +35,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 
 	<Menu name="ktexteditor_popup">
 		<Merge/>
+		<Action name="code_navigation_menu"/>
 		<Action name="mark_block"/>
 		<Action name="unmark_block"/>
 		<Menu name="run"><text>&Run</text>



More information about the rkward-tracker mailing list