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

3

7

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

Z answered 15/5, 2018 at 17:15 Comment(0)
U
2

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.

Unhopedfor answered 1/6, 2018 at 22:55 Comment(1)
This answer has taken a hell of Matic's effort, so if anyone feels like it, please don't forget to reward him with some good upvotes for all his effort, thanks in advance!Z
P
5

QsciScintilla does not seem to expose all of Scintilla's functionality, however through SendScintilla we can get access to the rest of it, as you seem to have already discovered. So the Commenter class may look something like this (the tricky part is restoring selections as Scintilla unselects on insertion):

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
        self.sel_regions = []

    def toggle_comments(self):
        lines = self.selected_lines()
        if len(lines) <= 0:
            return
        all_commented = True
        for line in lines:
            if not self.sci.text(line).strip().startswith(self.comment_str):
                all_commented = False
        if not all_commented:
            self.comment_lines(lines)
        else:
            self.uncomment_lines(lines)

    def selections(self):
        regions = []
        for i in range(self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONS)):
            regions.append({
                'begin': self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNSTART, i),
                'end': self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNEND, i)
            })

        return regions

    def selected_lines(self):
        self.sel_regions = []
        all_lines = []
        regions = self.selections()
        for r in regions:
            start_line = self.sci.SendScintilla(QsciScintilla.SCI_LINEFROMPOSITION, r['begin'])
            end_line = self.sci.SendScintilla(QsciScintilla.SCI_LINEFROMPOSITION, r['end'])
            for cur_line in range(start_line, end_line + 1):
                if not cur_line in all_lines:
                    all_lines.append(cur_line)
            if r['begin'] <= r['end']:
                self.sel_regions.append(r)
        return all_lines

    def comment_lines(self, lines):
        indent = self.sci.indentation(lines[0])
        for line in lines:
            indent = min(indent, self.sci.indentation(line))
        self.sci.beginUndoAction()
        for line in lines:
            self.adjust_selections(line, indent)
            self.sci.insertAt(self.comment_str, line, indent)
        self.sci.endUndoAction()
        self.restore_selections()

    def uncomment_lines(self, lines):
        self.sci.beginUndoAction()
        for line in lines:
            line_start = self.sci.SendScintilla(QsciScintilla.SCI_POSITIONFROMLINE, line)
            line_end = self.sci.SendScintilla(QsciScintilla.SCI_GETLINEENDPOSITION, line)
            if line_start == line_end:
                continue
            if line_end - line_start < len(self.comment_str):
                continue
            done = False
            for c in range(line_start, line_end - len(self.comment_str) + 1):
                source_str = self.sci.text(c, c + len(self.comment_str))
                if(source_str == self.comment_str):
                    self.sci.SendScintilla(QsciScintilla.SCI_DELETERANGE, c, len(self.comment_str))
                    break
        self.sci.endUndoAction()

    def restore_selections(self):
        if(len(self.sel_regions) > 0):
            first = True
            for r in self.sel_regions:
                if first:
                    self.sci.SendScintilla(QsciScintilla.SCI_SETSELECTION, r['begin'], r['end'])
                    first = False
                else:
                    self.sci.SendScintilla(QsciScintilla.SCI_ADDSELECTION, r['begin'], r['end'])

    def adjust_selections(self, line, indent):
        for r in self.sel_regions:
            if self.sci.positionFromLineIndex(line, indent) <= r['begin']:
                r['begin'] += len(self.comment_str)
                r['end'] += len(self.comment_str)
            elif self.sci.positionFromLineIndex(line, indent) < r['end']:
                r['end'] += len(self.comment_str)



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_comments)


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()
Peppermint answered 20/5, 2018 at 2:11 Comment(4)
Tyvm, your answer is very good one (it's usable as it is). Few differences I've noticed though when comparing your feature with SublimeText3's. Difference1, Difference2 and also, look how sublimetext3 has a whitespace after the comment symbol "//" which makes regexing/parsing an easier task. I'll be accepting & giving the bounties to the final answer which is more similar to Sublime's. Yours is a serious candidate for the final prize, +1Z
Also, after testing further I've noticed your feature won't deal correctly with folded regions, for instance: It works in this simple case correctly but in other cases code is buggy, Difference3Z
@BPL, the Difference3 may be because in QScintilla you need to select till the next line for the region to get selectedZook
Just to let you know I've decided to give the full ammount of bounties to this answer to keep my promise (i'd reward the closest answer meeting all requirements), otherwise you'd only received half of bounties and that'd be quite sad as you've put a lot of effort. That said, I won't accept the answer as valid because what I've explained on my previous comments.Z
U
2

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.

Unhopedfor answered 1/6, 2018 at 22:55 Comment(1)
This answer has taken a hell of Matic's effort, so if anyone feels like it, please don't forget to reward him with some good upvotes for all his effort, thanks in advance!Z
I
0

The following should demonstrate where you went wrong with the looping. I'll leave it to you to implement the toggle functionality.

UPDATED to include full source from original post and moved commenting to commenter class.

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()

    def comment_blocks(self):
        sci = self.sci
        comment_chars = self.comment_str
        selections = [(sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNSTART, i), sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONNEND, i)) for i in range(self.sci.SendScintilla(QsciScintilla.SCI_GETSELECTIONS))]
        def block_indentation(lineFrom, lineTo):
            """Get the minimum indentation for the line range"""
            indent = min(sci.indentation(line) for line in range(lineFrom, lineTo))
            return indent

        def comment(selFrom, selTo):
            lineFrom = selFrom[0]
            lineTo = selTo[0] + 1
            indent = block_indentation(lineFrom, lineTo)
            for line in range(lineFrom, lineTo):
                text = sci.text(line).lstrip()
                if not text:
                    sci.insertAt(' ' * indent + comment_chars, line, 0)  # Make sure blank lines are preserved
                else:
                    sci.insertAt(comment_chars, line, indent)
            # sci.setSelection(lineFrom, selFrom[1], lineTo, selTo[1])  # Restore selection TODO: for muliple selections see init_test_selections()
        sci.beginUndoAction()
        for sel in reversed(selections):  # Positions will change due to inserted comment chars..so run loop in reverse
            sel_from = sci.lineIndexFromPosition(sel[0])
            sel_to = sci.lineIndexFromPosition(sel[1])
            comment(sel_from, sel_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)
        QShortcut(QKeySequence("Ctrl+7"), self, self.commenter.comment_blocks)


    def init_test_selections(self):
        # initialize multiple selections
        offset1 = self.positionFromLineIndex(21, 0)
        offset2 = self.positionFromLineIndex(29, 5)
        self.SendScintilla(self.SCI_SETSELECTION, offset1, offset2)

        offset1 = self.positionFromLineIndex(31, 0)
        offset2 = self.positionFromLineIndex(33, 20)
        self.SendScintilla(self.SCI_ADDSELECTION, offset1, offset2)

        offset1 = self.positionFromLineIndex(37, 0)
        offset2 = self.positionFromLineIndex(39, 5)
        self.SendScintilla(self.SCI_ADDSELECTION, offset1, offset2)

    def init_test_selections2(self):
        # initialize multiple selections
        offset1 = self.positionFromLineIndex(11, 0)
        offset2 = self.positionFromLineIndex(13, 1)
        self.SendScintilla(self.SCI_SETSELECTION, offset1, offset2)

        offset1 = self.positionFromLineIndex(15, 0)
        offset2 = self.positionFromLineIndex(17, 1)
        self.SendScintilla(self.SCI_ADDSELECTION, offset1, offset2)


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.init_test_selections()
    # ex.init_test_selections2()

    ex.resize(800, 600)
    ex.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
Intercessory answered 19/5, 2018 at 22:8 Comment(5)
First of all, thanks for the effort but I fail to see how I could use your posted functions at all to implement the whole feature. Let's forget for a moment the fact your answer isn't covering any of the posted requirements in the question... it isn't covering even one of the most simplest cases. Please take a look here -> before and after. After few attempts it seems to me that's just buggy code. Thanks anyway!Z
It's not needed you post my example code at all, when tested your code I've used your functions in a QScintilla like this. Now, compare the right behaviour of what I'm trying to achieve here with how your code behaves, hope this clarifies a little bit more what I meant by buggy code :) . In what way would your code help me to achieve the expected behaviour?Z
I updated to replace the commenting done via Ctrl+7 with the version that I've posted.Intercessory
Multiple selections of entire lines should work. I did not address multiple selections within a single line or restoring selections after commenting.Intercessory
Check again my previous comment to understand why I won't accept the current answer... As explained, I don't see how your code (in the current shape) would help to achieve the final goal at all. Plus, look in the video how the selections are updated/preserved nicely when toggling.Z

© 2022 - 2024 — McMap. All rights reserved.