Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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:

  • ScintillaDoc
  • QScintilla2
  • MultipleSelectionAndVirtualSpace

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

like image 516
BPL Avatar asked May 15 '18 17:05

BPL


2 Answers

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()
like image 134
mnistic Avatar answered Nov 15 '22 04:11

mnistic


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.

like image 2
12 revs, 3 users 92% Avatar answered Nov 15 '22 03:11

12 revs, 3 users 92%