pythonpyqtresize

How can I implement a responsive QPlainTextEdit?


Here's an MRE:

import sys, random
from PyQt5 import QtWidgets, QtCore, QtGui

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        main_splitter = MainSplitter(self)
        self.setCentralWidget(main_splitter)
        main_splitter.setOrientation(QtCore.Qt.Vertical)
        main_splitter.setStyleSheet('background-color: red; border: 1px solid pink;');
        
        # top component of vertical splitter: a QFrame to hold horizontal splitter and breadcrumbs QPlainTextEdit
        top_frame = QtWidgets.QFrame()
        top_frame_layout = QtWidgets.QVBoxLayout()
        top_frame.setLayout(top_frame_layout)
        top_frame.setStyleSheet('background-color: green; border: 1px solid green;');
        main_splitter.addWidget(top_frame)
        
        # top component of top_frame: horizontal splitter
        h_splitter = HorizontalSplitter()
        h_splitter.setStyleSheet('background-color: cyan; border: 1px solid orange;')
        top_frame_layout.addWidget(h_splitter)

        # bottom component of top_frame: QPlainTextEdit (for "breadcrumbs")
        self.breadcrumbs_pte = BreadcrumbsPTE(top_frame)
        top_frame_layout.addWidget(self.breadcrumbs_pte)
        self.text = 'some plain text '
        self.breadcrumbs_pte.setPlainText(self.text * 50)
        self.breadcrumbs_pte.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
        self.breadcrumbs_pte.setStyleSheet('background-color: orange; border: 1px solid blue;');
        self.breadcrumbs_pte.setMinimumWidth(300)
        self.breadcrumbs_pte.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)

        # bottom component of vertical splitter: a QFrame
        bottom_panel = BottomPanel(main_splitter)
        bottom_panel.setStyleSheet('background-color: magenta; border: 1px solid cyan;')
        main_splitter.addWidget(bottom_panel)

    def resizeEvent(self, *args):
        print('resize event...')
        n_repeat = random.randint(10, 50)
        self.breadcrumbs_pte.setPlainText(self.text * n_repeat)
        super().resizeEvent(*args)

class BreadcrumbsPTE(QtWidgets.QPlainTextEdit):
    def sizeHint(self):
        return QtCore.QSize(500, 100)

class MainSplitter(QtWidgets.QSplitter):
    def sizeHint(self):
        return QtCore.QSize(40, 150)

class HorizontalSplitter(QtWidgets.QSplitter):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setOrientation(QtCore.Qt.Horizontal)

        self.left_panel = LeftPanel()
        self.left_panel.setStyleSheet('background-color: yellow; border: 1px solid black;');
        self.addWidget(self.left_panel)
        self.left_panel.setMinimumHeight(150)

        right_panel = QtWidgets.QFrame()
        right_panel.setStyleSheet('background-color: black; border: 1px solid blue;');
        self.addWidget(right_panel)

        # to achieve 66%-33% widths ratio
        self.setStretchFactor(0, 20)
        self.setStretchFactor(1, 10)
         
    def sizeHint(self):
        return self.left_panel.sizeHint()
    
class LeftPanel(QtWidgets.QFrame):
    def sizeHint(self):
        return QtCore.QSize(150, 250)

class BottomPanel(QtWidgets.QFrame):
    def sizeHint(self):
        return QtCore.QSize(30, 180)

app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

If you run it, you'll see that if you adjust the size of the window the text in the QPlainTextEdit changes each time.

What I'm trying to achieve: I want the text in the QPlainTextEdit to adjust the size of that component and the component above it (HorizontalSplitter) so that the QPlainTextEdit just contains perfectly the text, not leaving space at the bottom.

I want that to happen so that no adjustment to the size of the main window occurs (obviously if this happened, this would, as the code is written, currently lead to infinite triggering of the MainWindow.resizeEvent()).

The tutorials on Qt/PyQt just don't seem to give a comprehensive technical explanation of how all the various mechanisms work and how they interact. For example, I know that sizeHint plays a crucial role relating to sizing and layouts, but, short of trying to examine the source code in depth, I don't know how I can improve my understanding.

For example, I tried infinite permutations of commenting out sizeHint on the various classes here, including commenting them all out: but self.top_pte.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) never seems to work (i.e. as I want it to!).

NB if necessary, BottomPanel (i.e. 2nd child of the vertical QSplitter) can be set with minimum and maximum height (i.e. fixed height) to make things simpler. The main aim is to get the horizontal QSplitter and the QPlainTextEdit to adjust so that the latter's height is just perfectly adjusted for the text it contains...


Solution

  • I found an answer after several hours of experimenting. I don't know whether some of these techniques might be considered controversial (or just bad). If musicamante takes a look at this I'd be interested in your view.

    import sys, random
    from PyQt5 import QtWidgets, QtCore, QtGui
    
    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
            main_splitter = MainSplitter(self)
            self.setCentralWidget(main_splitter)
            main_splitter.setOrientation(QtCore.Qt.Vertical)
            main_splitter.setStyleSheet('background-color: red; border: 1px solid pink;');
            
            # top component of vertical splitter: a QFrame to hold horizontal splitter and breadcrumbs QPlainTextEdit
            top_frame = TopFrame(self)
            top_frame_layout = QtWidgets.QVBoxLayout()
            top_frame.setLayout(top_frame_layout)
            top_frame.setStyleSheet('background-color: green; border: 1px solid green;');
            main_splitter.addWidget(top_frame)
            
            # top component of top_frame: horizontal splitter
            h_splitter = HorizontalSplitter()
            h_splitter.setStyleSheet('background-color: cyan; border: 1px solid orange;')
            top_frame_layout.addWidget(h_splitter, stretch=1)
    
            # bottom component of top_frame: QPlainTextEdit (for "breadcrumbs")
            self.breadcrumbs_pte = BreadcrumbsPTE(top_frame)
            top_frame_layout.addWidget(self.breadcrumbs_pte)
            self.text = 'some plain text '
            self.breadcrumbs_pte.setPlainText(self.text * 50 + '... END')
            self.breadcrumbs_pte.setStyleSheet('background-color: orange; border: 1px solid blue;');
            self.breadcrumbs_pte.setMinimumWidth(300)
    
            # bottom component of vertical splitter: a QFrame
            bottom_panel = BottomPanel(main_splitter)
            bottom_panel.setStyleSheet('background-color: magenta; border: 1px solid cyan;')
            main_splitter.addWidget(bottom_panel)
    
        def resizeEvent(self, *args):
            print('WINDOW resize event...')
            n_repeat = random.randint(10, 50)
            self.breadcrumbs_pte.setPlainText(self.text * n_repeat + '... END')
            super().resizeEvent(*args)
            self.breadcrumbs_pte.resizeEvent(*args)
    
    class TopFrame(QtWidgets.QFrame):
        pass
    
    class BreadcrumbsPTE(QtWidgets.QPlainTextEdit):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            # print(f'+++ self.wordWrapMode() {self.wordWrapMode()}') # 4 (QTextOption::WrapAtWordBoundaryOrAnywhere)
    
        def resizeEvent(self, *args):
            print('BPTE resize event...')
            plain_text = self.document().toPlainText()
            print(f'+++ RESIZE len(plain_text) {len(plain_text)}')
            super().resizeEvent(*args)
            # fortunately it seemed possible to avoid this sort of technique:
            # QtCore.QTimer.singleShot(0, self.updateGeometry)
            self.updateGeometry()
    
        def sizeHint(self):
            super_hint = super().sizeHint()
            actual_size = self.size()
            print(f'+++ super_hint {super_hint} actual_size {actual_size}') # 
            document = QtGui.QTextDocument()
            plain_text = self.document().toPlainText()
            # print(f'+++ len(plain_text) {len(plain_text)}')
            document.setPlainText(plain_text)
            document.setTextWidth(float(actual_size.width()))
            document_float_size = document.size()
            document_int_size = QtCore.QSize(int(document_float_size.width()), int(document_float_size.height()))
            # print(f'+++ document_int_size {document_int_size}')
            self.updateGeometry()
            return document_int_size
    
        def minimumSizeHint(self):
            return self.sizeHint()
    
    
    class MainSplitter(QtWidgets.QSplitter):
        pass
    
    class HorizontalSplitter(QtWidgets.QSplitter):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setOrientation(QtCore.Qt.Horizontal)
    
            self.left_panel = LeftPanel()
            self.left_panel.setStyleSheet('background-color: yellow; border: 1px solid black;');
            self.addWidget(self.left_panel)
            self.left_panel.setMinimumHeight(150)
    
            right_panel = QtWidgets.QFrame()
            right_panel.setStyleSheet('background-color: black; border: 1px solid blue;');
            self.addWidget(right_panel)
    
            # to achieve 66%-33% widths ratio
            self.setStretchFactor(0, 20)
            self.setStretchFactor(1, 10)
             
        def sizeHint(self):
            return self.left_panel.sizeHint()
    
    class LeftPanel(QtWidgets.QFrame):
        def sizeHint(self):
            return QtCore.QSize(150, 500)
    
    class BottomPanel(QtWidgets.QFrame):
        def sizeHint(self):
            return QtCore.QSize(30, 180)
    
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec_()
    

    I noticed that you could quite often get a main window resize event with nothing else (e.g. sizeHint on other components) happening ... so thought the best thing to do was to relay this resizeEvent directly on to the BreadcrumbsPTE. I don't know whether there's a better way of forcing the descendant widgets to trigger resizeEvent. Later yes, PTE has a signal textChanged ...

    sizeHint triggers updateGeometry, and minimumSizeHint obviously just runs sizeHint, so it does that too. But BreadcrumbsPTE.resizeEvent also seems to need to trigger updateGeometry for things to work.

    I was puzzled by the QTextDocument delivered by BreadcrumbsPTE.document in BreadcrumbsPTE.sizeHint: I couldn't seem to get this to change size: maybe it's constrained by its parent (or container), i.e. the BreadcrumbsPTE: for that reason I seemed to have to make a separate QTextDocument fill it with the text and set its width ... in order for it to tell me what the height of the wrapped text would then be...