[LyX/master] Extended comment and indentation for source code

Jean-Marc Lasgouttes lasgouttes at lyx.org
Tue Jan 31 22:09:52 UTC 2023


commit 439c09912482489e79399fd9e10fa2b7f717f565
Author: Daniel Ramoeller <d.lyx at web.de>
Date:   Sat Jan 14 13:22:57 2023 +0100

    Extended comment and indentation for source code
    
    - automatically inherit indentation from previous block
    - (un)indent blocks
    - (un)comment blocks
    - add feature to show tabs and spaces
---
 src/frontends/qt/GuiDocument.cpp      |   21 +--
 src/frontends/qt/GuiSourceEdit.cpp    |  311 +++++++++++++++++++++++++++++++++
 src/frontends/qt/GuiSourceEdit.h      |   94 ++++++++++
 src/frontends/qt/LaTeXHighlighter.cpp |   18 ++
 src/frontends/qt/Makefile.am          |    2 +
 src/frontends/qt/ui/LocalLayoutUi.ui  |    9 +-
 src/frontends/qt/ui/PreambleUi.ui     |    9 +-
 7 files changed, 442 insertions(+), 22 deletions(-)

diff --git a/src/frontends/qt/GuiDocument.cpp b/src/frontends/qt/GuiDocument.cpp
index 60f9f84..7b4cedb 100644
--- a/src/frontends/qt/GuiDocument.cpp
+++ b/src/frontends/qt/GuiDocument.cpp
@@ -479,7 +479,7 @@ PreambleModule::PreambleModule(QWidget * parent)
 	// @ is letter in the LyX user preamble
 	(void) new LaTeXHighlighter(preambleTE->document(), true);
 	preambleTE->setFont(guiApp->typewriterSystemFont());
-	preambleTE->setWordWrapMode(QTextOption::NoWrap);
+	preambleTE->setCommentMarker("%");
 	setFocusProxy(preambleTE);
 	// Install event filter on find line edit to capture return/enter key
 	findLE->installEventFilter(this);
@@ -489,15 +489,6 @@ PreambleModule::PreambleModule(QWidget * parent)
 	connect(editPB, SIGNAL(clicked()), this, SLOT(editExternal()));
 	connect(findLE, SIGNAL(returnPressed()), this, SLOT(findText()));
 	checkFindButton();
-	int const tabStop = 4;
-	QFontMetrics metrics(preambleTE->currentFont());
-#if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0))
-	// horizontalAdvance() is available starting in 5.11.0
-	// setTabStopDistance() is available starting in 5.10.0
-	preambleTE->setTabStopDistance(tabStop * metrics.horizontalAdvance(' '));
-#else
-	preambleTE->setTabStopWidth(tabStop * metrics.width(' '));
-#endif
 }
 
 
@@ -627,20 +618,10 @@ LocalLayout::LocalLayout(QWidget * parent)
 	: UiWidget<Ui::LocalLayoutUi>(parent), current_id_(nullptr), validated_(false)
 {
 	locallayoutTE->setFont(guiApp->typewriterSystemFont());
-	locallayoutTE->setWordWrapMode(QTextOption::NoWrap);
 	connect(locallayoutTE, SIGNAL(textChanged()), this, SLOT(textChanged()));
 	connect(validatePB, SIGNAL(clicked()), this, SLOT(validatePressed()));
 	connect(convertPB, SIGNAL(clicked()), this, SLOT(convertPressed()));
 	connect(editPB, SIGNAL(clicked()), this, SLOT(editExternal()));
-	int const tabStop = 4;
-	QFontMetrics metrics(locallayoutTE->currentFont());
-#if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0))
-	// horizontalAdvance() is available starting in 5.11.0
-	// setTabStopDistance() is available starting in 5.10.0
-	locallayoutTE->setTabStopDistance(tabStop * metrics.horizontalAdvance(' '));
-#else
-	locallayoutTE->setTabStopWidth(tabStop * metrics.width(' '));
-#endif
 }
 
 
diff --git a/src/frontends/qt/GuiSourceEdit.cpp b/src/frontends/qt/GuiSourceEdit.cpp
new file mode 100644
index 0000000..9b008a5
--- /dev/null
+++ b/src/frontends/qt/GuiSourceEdit.cpp
@@ -0,0 +1,311 @@
+// -*- C++ -*-
+/**
+ * \file GuiSourceEdit.cpp
+ * This file is part of LyX, the document processor.
+ * Licence details can be found in the file COPYING.
+ *
+ * Full author contact details are available in file CREDITS.
+ */
+
+#include <config.h>
+
+#include "GuiSourceEdit.h"
+
+#include "qt_helpers.h"
+
+#include <QMenu>
+#include <QRegularExpression>
+
+namespace lyx {
+namespace frontend {
+
+GuiSourceEdit::GuiSourceEdit(QWidget *parent) : QTextEdit(parent)
+{
+	setWordWrapMode(QTextOption::NoWrap);
+	// Set the default tab stop
+	setTabStop(tabStop_);
+	setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(this, SIGNAL(customContextMenuRequested(QPoint)),
+	        this, SLOT(showMenu(QPoint)));
+}
+
+void GuiSourceEdit::showMenu(const QPoint& pos)
+{
+	// Move the cursor to the click position unless clicked within selection
+	QTextCursor cursor = textCursor();
+	int const textPos = cursorForPosition(pos).position();
+	bool const textPosInSel = textCursor().selectionStart() <= textPos &&
+	                          textPos <= textCursor().selectionEnd();
+	if (!textCursor().hasSelection() || !textPosInSel) {
+		cursor.setPosition(textPos);
+		setTextCursor(cursor);
+	}
+	// The standard menu
+	QMenu * menu = QTextEdit::createStandardContextMenu();
+	QAction * firstAction = menu->actions().at(0);
+	// Insert toggle comment entry at the top
+	QKeySequence keySeq = QKeySequence(Qt::ControlModifier | Qt::Key_Slash);
+	QAction * toggleComment = new QAction(qt_("Toggle Comment") + "\t" +
+		keySeq.toString(QKeySequence::NativeText), menu);
+	connect(toggleComment, SIGNAL(triggered()), this, SLOT(toggleComment()));
+	menu->insertAction(firstAction, toggleComment);
+	// Insert toggle spaces and tabs entry at the top
+	QAction * showTabsAndSpaces = new QAction(qt_("Show Tabs and Spaces"), menu);
+	showTabsAndSpaces->setCheckable(true);
+	auto currentFlags = document()->defaultTextOption().flags();
+	showTabsAndSpaces->setChecked(currentFlags &
+	                              QTextOption::ShowTabsAndSpaces);
+	connect(showTabsAndSpaces, SIGNAL(triggered()), this,
+	        SLOT(toggleShowTabsAndSpaces()));
+	menu->insertAction(firstAction, showTabsAndSpaces);
+	// Add separator to default entries
+	menu->insertSeparator(firstAction);
+	menu->exec(mapToGlobal(pos));
+}
+
+void GuiSourceEdit::setCommentMarker(QString marker)
+{
+	commentMarker_ = marker;
+}
+
+void GuiSourceEdit::setTabStop(int spaces)
+{
+	tabStop_ = spaces;
+	QFontMetrics metrics(currentFont());
+#if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0))
+	// horizontalAdvance() is available starting in 5.11.0
+	// setTabStopDistance() is available starting in 5.10.0
+	setTabStopDistance(tabStop_ * metrics.horizontalAdvance(' '));
+#else
+	setTabStopWidth(tabStop_ * metrics.width(' '));
+#endif
+}
+
+void GuiSourceEdit::setFont(const QFont & font)
+{
+	QTextEdit::setFont(font);
+	// Re-calculate tabstop width based on new font
+	setTabStop(tabStop_);
+}
+
+QTextBlock GuiSourceEdit::blockAtSelPos(Position position) const
+{
+	QTextCursor cursor = textCursor();
+	QTextDocument * doc = cursor.document();
+	int pos = position == START ? qMin(cursor.anchor(), cursor.position()) :
+	                              qMax(cursor.anchor(), cursor.position());
+	return doc->findBlock(pos);
+}
+
+QTextCursor GuiSourceEdit::cursorAt(int position) const {
+	// Create text cursor
+	QTextCursor cursor = textCursor();
+	// Move the cursor to position
+	cursor.setPosition(position);
+	return cursor;
+}
+
+void GuiSourceEdit::removeMarker(int positionStart, QString marker,
+                                 bool addedSpace)
+{
+	// Create text cursor
+	QTextCursor cursor = cursorAt(positionStart);
+	QTextBlock const block = cursor.block();
+	QString blockText = block.text();
+	int const index = blockText.indexOf(marker);
+	int length = marker.length();
+	if (index != -1) {
+		// Check for extra space after marker
+		if (addedSpace && blockText.remove(0, index + length).startsWith(" "))
+			++length;
+		cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor,
+		                    index);
+		cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor,
+		                    length);
+		cursor.deleteChar();
+	}
+}
+
+void GuiSourceEdit::insertMarkerAtIndentation(int positionStart, QString marker,
+                                              bool addedSpace, int indentation)
+{
+	// Create text cursor
+	QTextCursor cursor = cursorAt(positionStart);
+	// Move the cursor to the given indentation
+	int i = 0;
+	while (i < indentation) {
+		if (toPlainText().at(cursor.position()) == '\t')
+			i += tabStop_;
+		else
+			++i;
+		cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
+	}
+	// Add the comment marker
+	cursor.insertText(marker + (addedSpace ? " " : ""), QTextCharFormat());
+}
+
+void GuiSourceEdit::modifyMarkerInSel(QString marker, Modification modification,
+                                      bool allEmpty, bool addedSpace)
+{
+	QTextBlock const startBlock = blockAtSelPos(START);
+	QTextBlock const endBlock = blockAtSelPos(END);
+	QTextBlock const endBlockNext = endBlock.next();
+	// Create text cursor
+	QTextCursor cursor = textCursor();
+	cursor.beginEditBlock();
+	for (QTextBlock block = startBlock; block != endBlockNext;
+	     block = block.next()) {
+		if (modification == REMOVE)
+			removeMarker(block.position(), marker, addedSpace);
+		// Disregard white space blocks
+		else if (allEmpty || !block.text().trimmed().isEmpty()) {
+			insertMarkerAtIndentation(block.position(), marker, addedSpace,
+			                          selMinIndentation(allEmpty));
+		}
+	}
+	cursor.endEditBlock();
+}
+
+int GuiSourceEdit::getIndentation(QString text) const
+{
+	int tabs = 0;
+	for (QChar c : text) {
+		if (c == QChar::Tabulation)
+			++tabs;
+		if (!c.isSpace())
+			break;
+	}
+	return tabs;
+}
+
+QString GuiSourceEdit::getIndentationString(QString text) const
+{
+	// Regex to capture indentation string (i.e. spaces and tab stops)
+	QRegularExpression static re("^( |\t)+");
+	return re.match(text).captured(0);
+}
+
+void GuiSourceEdit::newLineWithInheritedIndentation()
+{
+	QTextCursor cursor = textCursor();
+	cursor.beginEditBlock();
+	// Start new line
+	cursor.insertText("\n", QTextCharFormat());
+	// Insert as many tabstops as on the previous block
+	QTextBlock const previousBlock = blockAtSelPos(START).previous();
+	QString const indentation = getIndentationString(previousBlock.text());
+	cursor.insertText(indentation);
+	cursor.endEditBlock();
+}
+
+int GuiSourceEdit::getLengthInSpaces(QString const & text) const
+{
+	// Replace tab stops by spaces and return length
+	return QString(text).replace("\t", QString(" ").repeated(tabStop_))
+			.length();
+}
+
+int GuiSourceEdit::selMinIndentation(bool allEmpty) const
+{
+	QTextBlock const startBlock = blockAtSelPos(START);
+	QTextBlock const endBlock = blockAtSelPos(END);
+	QTextBlock const endBlockNext = endBlock.next();
+	QString minIndentationString = getIndentationString(startBlock.text());
+	for (QTextBlock block = startBlock; block != endBlockNext;
+	     block = block.next()) {
+		QString text = block.text();
+		if (allEmpty || !text.trimmed().isEmpty()) {
+			QString curIndentationString = getIndentationString(text);
+			// Chop off indentation until they have the same length
+			while (!minIndentationString.isEmpty()) {
+				int minLen = getLengthInSpaces(minIndentationString);
+				int curLen = getLengthInSpaces(curIndentationString);
+				if (minLen == curLen)
+					break;
+				else if (minLen > curLen)
+					minIndentationString.chop(1);
+				else
+					curIndentationString.chop(1);
+			}
+		}
+	}
+	return getLengthInSpaces(minIndentationString);
+}
+
+bool GuiSourceEdit::selBlocksStartWith(QString marker) const
+{
+	QTextBlock const startBlock = blockAtSelPos(START);
+	QTextBlock const endBlock = blockAtSelPos(END);
+	QTextBlock const endBlockNext = endBlock.next();
+	for (QTextBlock block = startBlock; block != endBlockNext;
+	     block = block.next()) {
+		QString const blockText = block.text();
+		QString trimmedText = blockText.trimmed();
+		// Disregard white space blocks
+		if (trimmedText.isEmpty())
+			continue;
+		else if (!trimmedText.startsWith(marker))
+			return false;
+	}
+	return true;
+}
+
+bool GuiSourceEdit::selBlocksWhiteSpace() const
+{
+	QTextBlock const startBlock = blockAtSelPos(START);
+	QTextBlock const endBlockNext = blockAtSelPos(END).next();
+	for (QTextBlock block = startBlock; block != endBlockNext;
+	     block = block.next()) {
+		QString const blockText = block.text();
+		QString const trimmedText = blockText.trimmed();
+		// Disregard white space blocks
+		if (trimmedText.isEmpty())
+			continue;
+		else
+			return false;
+	}
+	return true;
+}
+
+void GuiSourceEdit::toggleComment()
+{
+	bool const allEmpty = selBlocksWhiteSpace();
+	modifyMarkerInSel(commentMarker_,
+	                  !allEmpty && selBlocksStartWith(commentMarker_) ?
+	                      REMOVE : INSERT,
+	                  allEmpty, addedSpaceAfterComment_);
+}
+
+void GuiSourceEdit::toggleShowTabsAndSpaces()
+{
+	QTextOption option = document()->defaultTextOption();
+	auto currentFlags = document()->defaultTextOption().flags();
+	if (currentFlags & QTextOption::ShowTabsAndSpaces)
+		currentFlags &= ~QTextOption::ShowTabsAndSpaces;
+	else
+		currentFlags |= QTextOption::ShowTabsAndSpaces;
+	option.setFlags(currentFlags);
+	document()->setDefaultTextOption(option);
+}
+
+void GuiSourceEdit::keyPressEvent(QKeyEvent *event)
+{
+	if (event->modifiers() == Qt::ControlModifier &&
+	    event->key() == Qt::Key_Slash)
+		toggleComment();
+	else if (event->key() == Qt::Key_Tab &&
+	         blockAtSelPos(START) != blockAtSelPos(END))
+		modifyMarkerInSel("\t", INSERT, selBlocksWhiteSpace(), false);
+	else if (event->key() == Qt::Key_Backtab)
+		modifyMarkerInSel("\t", REMOVE);
+	else if (event->key() == Qt::Key_Return)
+		newLineWithInheritedIndentation();
+	else
+		// Call base class for other events
+		QTextEdit::keyPressEvent(event);
+}
+
+} // namespace frontend
+} // namespace lyx
+
+#include "moc_GuiSourceEdit.cpp"
diff --git a/src/frontends/qt/GuiSourceEdit.h b/src/frontends/qt/GuiSourceEdit.h
new file mode 100644
index 0000000..4a8bc1c
--- /dev/null
+++ b/src/frontends/qt/GuiSourceEdit.h
@@ -0,0 +1,94 @@
+// -*- C++ -*-
+/**
+ * \file GuiSourceEdit.h
+ * This file is part of LyX, the document processor.
+ * Licence details can be found in the file COPYING.
+ *
+ * Full author contact details are available in file CREDITS.
+ */
+
+#ifndef GUISOURCEEDIT_H
+#define GUISOURCEEDIT_H
+
+#include <QTextEdit>
+#include <QKeyEvent>
+#include <QTextCursor>
+#include <QTextBlock>
+
+namespace lyx {
+namespace frontend {
+
+class GuiSourceEdit : public QTextEdit
+{
+	Q_OBJECT
+
+public:
+	explicit GuiSourceEdit(QWidget *parent = nullptr);
+	// Set and get tab stop in number of spaces
+	void setTabStop(int spaces);
+	int tabStop() const {return tabStop_; };
+	// Set and get line marker, e.g. "//"
+	void setCommentMarker(QString marker);
+	QString commentMarker() const { return commentMarker_; };
+	// Set and get whether a space is added after the line marker
+	void setAddedSpaceAfterComment(int spaces);
+	bool addedSpaceAfterComment() const { return addedSpaceAfterComment_; };
+	// Set font and update tab stop
+	void setFont(const QFont & font);
+
+private Q_SLOTS:
+	void showMenu(const QPoint& pos);
+	void toggleComment();
+	void toggleShowTabsAndSpaces();
+
+protected:
+	void keyPressEvent(QKeyEvent *event) override;
+
+private:
+	enum Modification { INSERT, REMOVE };
+	enum Position { START, END };
+
+	// Get block at selection start/end
+	QTextBlock blockAtSelPos(Position position) const;
+	// Whether all blocks start with marker
+	bool selBlocksStartWith(QString marker) const;
+	// Wehther all blocks are white space
+	bool selBlocksWhiteSpace() const;
+	// Get length of string substituting tabs for spaces
+	int getLengthInSpaces(QString const & text) const;
+	// Minimum tab indentation the paragraphs selected start with
+	int selMinIndentation(bool allEmpty) const;
+	// Number of tabs as indentation
+	int getIndentation(QString text) const;
+	// String of white space
+	QString getIndentationString(QString text) const;
+	// Copy of textCursor() [at position]
+	QTextCursor cursorAt(int position) const;
+
+	// From positionStart remove marker from the line
+	void removeMarker(int positionStart, QString marker,
+	                  bool addedSpace = false);
+	// From positionStart insert marker at indentation
+	void insertMarkerAtIndentation(int positionStart, QString marker,
+	                               bool addedSpace = false,
+	                               int indentation = 0);
+	// Modify (insert/remove) marker
+	void modifyMarkerInSel(QString marker,
+	                       Modification modification = INSERT,
+	                       bool allEmpty = false, bool addedSpace = false);
+	// Create a new line at cursor same indentation
+	void newLineWithInheritedIndentation();
+
+	// The comment marker
+	QString commentMarker_ = "#";
+	// The tab stop in spaces
+	int tabStop_ = 4;
+	// Whether a space gets added after the comment marker
+	bool addedSpaceAfterComment_ = true;
+};
+
+
+} // namespace frontend
+} // namespace lyx
+
+#endif // GUISOURCEEDIT_H
diff --git a/src/frontends/qt/LaTeXHighlighter.cpp b/src/frontends/qt/LaTeXHighlighter.cpp
index 80e7a09..51d5867 100644
--- a/src/frontends/qt/LaTeXHighlighter.cpp
+++ b/src/frontends/qt/LaTeXHighlighter.cpp
@@ -123,6 +123,14 @@ void LaTeXHighlighter::highlightBlock(QString const & text)
 		setFormat(index, length, keywordFormat);
 		index = exprKeyword.indexIn(text, index + length);
 	}
+	// White space
+	QRegExp exprWhiteSpace("\\s");
+	index = exprWhiteSpace.indexIn(text);
+	while (index >= 0) {
+		int length = exprWhiteSpace.matchedLength();
+		setFormat(index, length, commentFormat);
+		index = exprWhiteSpace.indexIn(text, index + length);
+	}
 	// %comment
 	// Treat a line as a comment starting at a percent sign
 	// * that is the first character in a line
@@ -237,6 +245,16 @@ void LaTeXHighlighter::highlightBlock(QString const & text)
 		match = exprKeyword.match(text, index + length);
 		index = match.capturedStart(0);
 	}
+	// White space
+	QRegularExpression exprWhiteSpace("\\s");
+	match = exprWhiteSpace.match(text);
+	index = match.capturedStart(0);
+	while (index >= 0) {
+		int length = match.capturedLength(0);
+		setFormat(index, length, commentFormat);
+		match = exprWhiteSpace.match(text, index + length);
+		index = match.capturedStart(0);
+	}
 	// %comment
 	// Treat a line as a comment starting at a percent sign
 	// * that is the first character in a line
diff --git a/src/frontends/qt/Makefile.am b/src/frontends/qt/Makefile.am
index 9ca258d..486b28f 100644
--- a/src/frontends/qt/Makefile.am
+++ b/src/frontends/qt/Makefile.am
@@ -118,6 +118,7 @@ SOURCEFILES = \
 	GuiSendto.cpp \
 	GuiSetBorder.cpp \
 	GuiShowFile.cpp \
+	GuiSourceEdit.cpp \
 	GuiSpellchecker.cpp \
 	GuiSymbols.cpp \
 	GuiTabular.cpp \
@@ -232,6 +233,7 @@ MOCHEADER = \
 	GuiSendto.h \
 	GuiSetBorder.h \
 	GuiShowFile.h \
+	GuiSourceEdit.h \
 	GuiSpellchecker.h \
 	GuiSymbols.h \
 	GuiTabularCreate.h \
diff --git a/src/frontends/qt/ui/LocalLayoutUi.ui b/src/frontends/qt/ui/LocalLayoutUi.ui
index 1a3e178..c2377b1 100644
--- a/src/frontends/qt/ui/LocalLayoutUi.ui
+++ b/src/frontends/qt/ui/LocalLayoutUi.ui
@@ -15,7 +15,7 @@
   </property>
   <layout class="QGridLayout" name="gridLayout_2">
    <item row="0" column="0">
-    <widget class="QTextEdit" name="locallayoutTE">
+    <widget class="lyx::frontend::GuiSourceEdit" name="locallayoutTE">
      <property name="toolTip">
       <string>Document-specific layout information</string>
      </property>
@@ -105,6 +105,13 @@
    </item>
   </layout>
  </widget>
+ <customwidgets>
+  <customwidget>
+   <class>lyx::frontend::GuiSourceEdit</class>
+   <extends>QTextEdit</extends>
+   <header>GuiSourceEdit.h</header>
+  </customwidget>
+ </customwidgets>
  <includes>
   <include location="local">qt_i18n.h</include>
  </includes>
diff --git a/src/frontends/qt/ui/PreambleUi.ui b/src/frontends/qt/ui/PreambleUi.ui
index eb27e91..dece982 100644
--- a/src/frontends/qt/ui/PreambleUi.ui
+++ b/src/frontends/qt/ui/PreambleUi.ui
@@ -54,7 +54,7 @@
     </widget>
    </item>
    <item row="0" column="0" colspan="3">
-    <widget class="QTextEdit" name="preambleTE">
+    <widget class="lyx::frontend::GuiSourceEdit" name="preambleTE">
      <property name="acceptRichText">
       <bool>false</bool>
      </property>
@@ -62,6 +62,13 @@
    </item>
   </layout>
  </widget>
+ <customwidgets>
+  <customwidget>
+   <class>lyx::frontend::GuiSourceEdit</class>
+   <extends>QTextEdit</extends>
+   <header>GuiSourceEdit.h</header>
+  </customwidget>
+ </customwidgets>
  <includes>
   <include location="local">qt_i18n.h</include>
  </includes>


More information about the lyx-cvs mailing list