I'm trying to implement a toggle comment feature in QScintilla that works with multiple selection. Unfortunately I don't know very well how to do it, so far I've come up with this code:
import sys
import re
import math
from PyQt5.Qt import * # noqa
from PyQt5.Qsci import QsciScintilla
from PyQt5 import Qsci
from PyQt5.Qsci import QsciLexerCPP
class Commenter():
def __init__(self, sci, comment_str):
self.sci = sci
self.comment_str = comment_str
def is_commented_line(self, line):
return line.strip().startswith(self.comment_str)
def toggle_comment_block(self):
sci = self.sci
line, index = sci.getCursorPosition()
if sci.hasSelectedText() and self.is_commented_line(sci.text(sci.getSelection()[0])):
self.uncomment_line_or_selection()
elif not self.is_commented_line(sci.text(line)):
self.comment_line_or_selection()
else:
start_line = line
while start_line > 0 and self.is_commented_line(sci.text(start_line - 1)):
start_line -= 1
end_line = line
lines = sci.lines()
while end_line < lines and self.is_commented_line(sci.text(end_line + 1)):
end_line += 1
sci.setSelection(start_line, 0, end_line, sci.lineLength(end_line))
self.uncomment_line_or_selection()
sci.setCursorPosition(line, index - len(self.comment_str))
def comment_line_or_selection(self):
sci = self.sci
if sci.hasSelectedText():
self.comment_selection()
else:
self.comment_line()
def uncomment_line_or_selection(self):
sci = self.sci
if sci.hasSelectedText():
self.uncomment_selection()
else:
self.uncomment_line()
def comment_line(self):
sci = self.sci
line, index = sci.getCursorPosition()
sci.beginUndoAction()
sci.insertAt(self.comment_str, line, sci.indentation(line))
sci.endUndoAction()
def uncomment_line(self):
sci = self.sci
line, index = sci.getCursorPosition()
if not self.is_commented_line(sci.text(line)):
return
sci.beginUndoAction()
sci.setSelection(
line, sci.indentation(line),
line, sci.indentation(line) + len(self.comment_str)
)
sci.removeSelectedText()
sci.endUndoAction()
def comment_selection(self):
sci = self.sci
if not sci.hasSelectedText():
return
line_from, index_from, line_to, index_to = sci.getSelection()
if index_to == 0:
end_line = line_to - 1
else:
end_line = line_to
sci.beginUndoAction()
for line in range(line_from, end_line + 1):
sci.insertAt(self.comment_str, line, sci.indentation(line))
sci.setSelection(line_from, 0, end_line + 1, 0)
sci.endUndoAction()
def uncomment_selection(self):
sci = self.sci
if not sci.hasSelectedText():
return
line_from, index_from, line_to, index_to = sci.getSelection()
if index_to == 0:
end_line = line_to - 1
else:
end_line = line_to
sci.beginUndoAction()
for line in range(line_from, end_line + 1):
if not self.is_commented_line(sci.text(line)):
continue
sci.setSelection(
line, sci.indentation(line),
line,
sci.indentation(line) + len(self.comment_str)
)
sci.removeSelectedText()
if line == line_from:
index_from -= len(self.comment_str)
if index_from < 0:
index_from = 0
if line == line_to:
index_to -= len(self.comment_str)
if index_to < 0:
index_to = 0
sci.setSelection(line_from, index_from, line_to, index_to)
sci.endUndoAction()
class Foo(QsciScintilla):
def __init__(self, parent=None):
super().__init__(parent)
# http://www.scintilla.org/ScintillaDoc.html#Folding
self.setFolding(QsciScintilla.BoxedTreeFoldStyle)
# Indentation
self.setIndentationsUseTabs(False)
self.setIndentationWidth(4)
self.setBackspaceUnindents(True)
self.setIndentationGuides(True)
# Set the default font
self.font = QFont()
self.font.setFamily('Consolas')
self.font.setFixedPitch(True)
self.font.setPointSize(10)
self.setFont(self.font)
self.setMarginsFont(self.font)
# Margin 0 is used for line numbers
fontmetrics = QFontMetrics(self.font)
self.setMarginsFont(self.font)
self.setMarginWidth(0, fontmetrics.width("000") + 6)
self.setMarginLineNumbers(0, True)
self.setMarginsBackgroundColor(QColor("#cccccc"))
# Indentation
self.setIndentationsUseTabs(False)
self.setIndentationWidth(4)
self.setBackspaceUnindents(True)
lexer = QsciLexerCPP()
lexer.setFoldAtElse(True)
lexer.setFoldComments(True)
lexer.setFoldCompact(False)
lexer.setFoldPreprocessor(True)
self.setLexer(lexer)
# Use raw messages to Scintilla here
# (all messages are documented here: http://www.scintilla.org/ScintillaDoc.html)
# Ensure the width of the currently visible lines can be scrolled
self.SendScintilla(QsciScintilla.SCI_SETSCROLLWIDTHTRACKING, 1)
# Multiple cursor support
self.SendScintilla(QsciScintilla.SCI_SETMULTIPLESELECTION, True)
self.SendScintilla(QsciScintilla.SCI_SETMULTIPASTE, 1)
self.SendScintilla(
QsciScintilla.SCI_SETADDITIONALSELECTIONTYPING, True)
# Comment feature goes here
self.commenter = Commenter(self, "//")
QShortcut(QKeySequence("Ctrl+7"), self,
self.commenter.toggle_comment_block)
def main():
app = QApplication(sys.argv)
ex = Foo()
ex.setText("""\
#include <iostream>
using namespace std;
void Function0() {
cout << "Function0";
}
void Function1() {
cout << "Function1";
}
void Function2() {
cout << "Function2";
}
void Function3() {
cout << "Function3";
}
int main(void) {
if (1) {
if (1) {
if (1) {
if (1) {
int yay;
}
}
}
}
if (1) {
if (1) {
if (1) {
if (1) {
int yay2;
}
}
}
}
return 0;
}\
""")
ex.resize(800, 600)
ex.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
Relevant Qscintilla docs live here:
Right now the feature just support one single selection/cursor and the way is commenting is really ugly. As you can see in the code, if you press ctrl while pressing the mouse you'll be able to create multiple cursors/selections already.
There are few things I don't know how to achieve right now though:
1) I'd like the comments to become well-aligned, that is, they should start at the same level of indentation. The existing feature right now produces ugly unaligned comments, example of what I call "well-aligned" comments:
2) Right now only one cursor/selection is being considered. How do I loop over the cursors/selections to apply a toggle_selection function?
3) I guess if you loop over the selections the result would be than having an even number of cursors in a particular line won't comment the line (comment, uncomment), for instance, something like this:
4) An odd number of cursors in a particular line would affect the line because (comment, uncomment, comment), for instance, something like this:
5) If you loop over the cursors/selections you'll end up producing output like the below one.
EDIT: 1st draft
class Commenter():
def __init__(self, sci, comment_str):
self.sci = sci
self.comment_str = comment_str
def selections(self):
regions = []
for i in range(self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONS)):
regions.append({
'begin': self.selection_start(i),
'end': self.selection_end(i)
})
return regions
def selection_start(self, selection):
return self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNSTART, selection)
def selection_end(self, selection):
return self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNEND, selection)
def text(self, *args):
return self.sci.text(*args)
def run(self):
send_scintilla = self.sci.SendScintilla
for region in self.selections():
print(region)
print(repr(self.text(region['begin'],region['end'])))
EDIT2: I've discovered the source code of this feature I'm trying to implement is available on SublimeText Default.sublime-package (zip file), comments.py. That code supports not only normal comments //
but also block comments /* ... */
. Main problem is porting that code to QScintilla seems to be quite tricky :/
Here is a simple example of a subclassed QsciScintilla editor with added SublimeText-like commenting by setting multiple selections using the Ctrl+Mouse
and then pressing Ctrl+K
.
UPDATE: Updated the commenting to comment/uncomment at the minimal indentation level of each selection and to merge adjacent selections.
# Import the PyQt5 module with some of the GUI widgets
import PyQt5.QtWidgets
import PyQt5.QtGui
import PyQt5.QtCore
# Import the QScintilla module
import PyQt5.Qsci
# Import Python's sys module needed to get the application arguments
import sys
"""
Custom editor with a simple commenting feature
similar to what SublimeText does
"""
class MyCommentingEditor(PyQt5.Qsci.QsciScintilla):
comment_string = "// "
line_ending = "\n"
def keyPressEvent(self, event):
# Execute the superclasses event
super().keyPressEvent(event)
# Check pressed key information
key = event.key()
key_modifiers = PyQt5.QtWidgets.QApplication.keyboardModifiers()
if (key == PyQt5.QtCore.Qt.Key_K and
key_modifiers == PyQt5.QtCore.Qt.ControlModifier):
self.toggle_commenting()
def toggle_commenting(self):
# Check if the selections are valid
selections = self.get_selections()
if selections == None:
return
# Merge overlapping selections
while self.merge_test(selections) == True:
selections = self.merge_selections(selections)
# Start the undo action that can undo all commenting at once
self.beginUndoAction()
# Loop over selections and comment them
for i, sel in enumerate(selections):
if self.text(sel[0]).lstrip().startswith(self.comment_string):
self.set_commenting(sel[0], sel[1], self._uncomment)
else:
self.set_commenting(sel[0], sel[1], self._comment)
# Select back the previously selected regions
self.SendScintilla(self.SCI_CLEARSELECTIONS)
for i, sel in enumerate(selections):
start_index = self.positionFromLineIndex(sel[0], 0)
# Check if ending line is the last line in the editor
last_line = sel[1]
if last_line == self.lines() - 1:
end_index = self.positionFromLineIndex(sel[1], len(self.text(last_line)))
else:
end_index = self.positionFromLineIndex(sel[1], len(self.text(last_line))-1)
if i == 0:
self.SendScintilla(self.SCI_SETSELECTION, start_index, end_index)
else:
self.SendScintilla(self.SCI_ADDSELECTION, start_index, end_index)
# Set the end of the undo action
self.endUndoAction()
def get_selections(self):
# Get the selection and store them in a list
selections = []
for i in range(self.SendScintilla(self.SCI_GETSELECTIONS)):
selection = (
self.SendScintilla(self.SCI_GETSELECTIONNSTART, i),
self.SendScintilla(self.SCI_GETSELECTIONNEND, i)
)
# Add selection to list
from_line, from_index = self.lineIndexFromPosition(selection[0])
to_line, to_index = self.lineIndexFromPosition(selection[1])
selections.append((from_line, to_line))
selections.sort()
# Return selection list
return selections
def merge_test(self, selections):
"""
Test if merging of selections is needed
"""
for i in range(1, len(selections)):
# Get the line numbers
previous_start_line = selections[i-1][0]
previous_end_line = selections[i-1][1]
current_start_line = selections[i][0]
current_end_line = selections[i][1]
if previous_end_line == current_start_line:
return True
# Merging is not needed
return False
def merge_selections(self, selections):
"""
This function merges selections with overlapping lines
"""
# Test if merging is required
if len(selections) < 2:
return selections
merged_selections = []
skip_flag = False
for i in range(1, len(selections)):
# Get the line numbers
previous_start_line = selections[i-1][0]
previous_end_line = selections[i-1][1]
current_start_line = selections[i][0]
current_end_line = selections[i][1]
# Test for merge
if previous_end_line == current_start_line and skip_flag == False:
merged_selections.append(
(previous_start_line, current_end_line)
)
skip_flag = True
else:
if skip_flag == False:
merged_selections.append(
(previous_start_line, previous_end_line)
)
skip_flag = False
# Add the last selection only if it was not merged
if i == (len(selections) - 1):
merged_selections.append(
(current_start_line, current_end_line)
)
# Return the merged selections
return merged_selections
def set_commenting(self, arg_from_line, arg_to_line, func):
# Get the cursor information
from_line = arg_from_line
to_line = arg_to_line
# Check if ending line is the last line in the editor
last_line = to_line
if last_line == self.lines() - 1:
to_index = len(self.text(to_line))
else:
to_index = len(self.text(to_line))-1
# Set the selection from the beginning of the cursor line
# to the end of the last selection line
self.setSelection(
from_line, 0, to_line, to_index
)
# Get the selected text and split it into lines
selected_text = self.selectedText()
selected_list = selected_text.split("\n")
# Find the smallest indent level
indent_levels = []
for line in selected_list:
indent_levels.append(len(line) - len(line.lstrip()))
min_indent_level = min(indent_levels)
# Add the commenting character to every line
for i, line in enumerate(selected_list):
selected_list[i] = func(line, min_indent_level)
# Replace the whole selected text with the merged lines
# containing the commenting characters
replace_text = self.line_ending.join(selected_list)
self.replaceSelectedText(replace_text)
def _comment(self, line, indent_level):
if line.strip() != "":
return line[:indent_level] + self.comment_string + line[indent_level:]
else:
return line
def _uncomment(self, line, indent_level):
if line.strip().startswith(self.comment_string):
return line.replace(self.comment_string, "", 1)
else:
return line
For the full example, see https://github.com/matkuki/qscintilla_docs/blob/master/examples/commenting.py
I used PyQt5 with QScintilla 2.10.4 and Python 3.6.