Search code examples
pythonselectionpysidebounding-boxqtextedit

Getting the bounding box of QTextEdit selection


I'm trying to get the bounding box of a series of text selections stored in a list. The bounding box is the smallest rectangle that can contain the whole selection. Each item in the list has a start and end point measured in characters from the beginning of the QTextEdit window and also a letter identifier. QTextEdit.cursorRect(cursor) should do this, but is producing nonsensical box dimensions:

id: A -- PySide.QtCore.QRect(0, 0, 1, 10)
id: B -- PySide.QtCore.QRect(0, 0, 1, 10)
id: C -- PySide.QtCore.QRect(0, 0, 1, 10)

The selections all start at different points so (0,0) is not correct in viewpoint coordinates. Also, some of them span several lines so the width and height should vary. The problem may be that the cursor is in a loop and I don't set it with setTextCursor until after the loop finishes. I do this because I'm also rendering the selections as highlights. How can I get cursorRect to work correctly or otherwise get a separate bounding box for each selection? Here is the code:

import sys
from PySide.QtCore import *
from PySide.QtGui import *

db = ((5,8,'A'),(20,35,'B'),(45,60,'C')) # start, end, and identifier of highlights

class TextEditor(QTextEdit):

    def __init__(self, parent=None):
        super().__init__(parent)
        text="This is example text that is several lines\nlong and also\nstrangely broken up and can be\nwrapped."
        self.setText(text)
        for n in range(0,len(db)):
            row = db[n]
            startChar = row[0]
            endChar = row[1]
            id = row[2]

            cursor = self.textCursor()
            cursor.setPosition(startChar)
            cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor, endChar-startChar)

            rect = self.cursorRect(cursor)
            print("id: %s -- %s" % (id,str(rect)))

            charfmt = cursor.charFormat()
            charfmt.setBackground(QColor(Qt.yellow))
            cursor.setCharFormat(charfmt)
        cursor.clearSelection()
        self.setTextCursor(cursor)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    editor = TextEditor()
    editor.show()
    app.exec_()
    sys.exit(app.exec_())

EDIT 1:

Here is the text from the program. I'll use CAPS for the highlighted text:

This IS example text THAT IS SEVERAL lines
loNG AND ALSO
STRangely broken up and can be
wrapped.

Let's assume every character is 10 pixels by 10 pixels. The "IS " starts 5 characters in and extends for 3 characters (including a space at the end). So, the upper left corner of the "I" would be at x=50,y=0. The bottom right corner of the space would be at x=80,y=10. If the bounding rectangle is given in coordinates, it would be (50,0,80,10). If the bounding rectangle is given in starting coordinates and size, it would be (50,0,30,10).

On the second line is a highlight that continues to the third line. Its leftmost character is the "S" at the start of line 3, which is at x=0. Its rightmost character is the "O" in "ALSO" which ends at x=130. Its topmost line is the second line, which begins at y=10. Its bottom most line is the third line, which ends at y=30. So, the bounding box would be (0,10,130,30) in coordinates or (0,10,130,20) in starting point and size.


Solution

  • Below is a first effort at finding the bounding boxes for all the highlighted sections specified by the information from the database. To make it clear exactly what each bounding box covers, the example script displays a corresponding rubber band. Here is what the results look like:

    enter image description here

    Resizing the window will automatically re-calculate the boxes according to the current word-wrapping. Note that this may mean several of the boxes overlap each other if the window becomes very small.

    I have implemented this as a separate method, because changes to the char format can potentially re-set the document layout. It is therefore more reliable to calculate the boxes in a second pass. And of course, this also allows for dynamic re-calculation whenever the window is resized.

    import sys
    from PySide.QtCore import *
    from PySide.QtGui import *
    
    db = ((5,8,'A'),(20,35,'B'),(45,60,'C')) # start, end, and identifier of highlights
    
    class TextEditor(QTextEdit):
        def __init__(self, parent=None):
            super().__init__(parent)
            text="This is example text that is several lines\nlong and also\nstrangely broken up and can be\nwrapped."
            self.setText(text)
            cursor = self.textCursor()
            for n in range(0,len(db)):
                row = db[n]
                startChar = row[0]
                endChar = row[1]
                id = row[2]
                cursor.setPosition(startChar)
                cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor, endChar-startChar)
                charfmt = cursor.charFormat()
                charfmt.setBackground(QColor(Qt.yellow))
                cursor.setCharFormat(charfmt)
            cursor.clearSelection()
            self.setTextCursor(cursor)
    
        def getBoundingRect(self, start, end):
            cursor = self.textCursor()
            cursor.setPosition(end)
            last_rect = end_rect = self.cursorRect(cursor)
            cursor.setPosition(start)
            first_rect = start_rect = self.cursorRect(cursor)
            if start_rect.y() != end_rect.y():
                cursor.movePosition(QTextCursor.StartOfLine)
                first_rect = last_rect = self.cursorRect(cursor)
                while True:
                    cursor.movePosition(QTextCursor.EndOfLine)
                    rect = self.cursorRect(cursor)
                    if rect.y() < end_rect.y() and rect.x() > last_rect.x():
                        last_rect = rect
                    moved = cursor.movePosition(QTextCursor.NextCharacter)
                    if not moved or rect.y() > end_rect.y():
                        break
                last_rect = last_rect.united(end_rect)
            return first_rect.united(last_rect)
    
    class Window(QWidget):
        def __init__(self):
            super(Window, self).__init__()
            self.edit = TextEditor(self)
            layout = QVBoxLayout(self)
            layout.addWidget(self.edit)
            self.boxes = []
    
        def showBoxes(self):
            while self.boxes:
                self.boxes.pop().deleteLater()
            viewport = self.edit.viewport()
            for start, end, ident in db:
                rect = self.edit.getBoundingRect(start, end)
                box = QRubberBand(QRubberBand.Rectangle, viewport)
                box.setGeometry(rect)
                box.show()
                self.boxes.append(box)
    
        def resizeEvent(self, event):
            self.showBoxes()
            super().resizeEvent(event)
    
    if __name__ == '__main__':
    
        app = QApplication(sys.argv)
        window = Window()
        window.setGeometry(800, 100, 350, 150)
        window.show()
        window.showBoxes()
        sys.exit(app.exec_())