pythonpyqtpyqt5scintillaqscintilla

How to implement a comment feature that works with multiple selections in QScintilla?


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:

enter image description here

2) Right now only one cursor/selection is being considered. How do I loop over the cursors/selections to apply a toggle_selection function?

enter image description here

enter image description here

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:

enter image description here

4) An odd number of cursors in a particular line would affect the line because (comment, uncomment, comment), for instance, something like this:

enter image description here

5) If you loop over the cursors/selections you'll end up producing output like the below one.

enter image description here

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 :/


Solution

  • 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.