Razero
Razero

Reputation: 341

highlight part of picture and select it

I'd like to implement a feature in my PyQt5 app, where I have this picture in my window, and if the user hovers the mouse over a circle, the color of the corresponding circle changes(or some colored overlay is applied over it), and when they click on it, it changes to another color and also triggers a click event.

enter image description here

Note: it doesn't have to be necessarily a picture, if it's easier to create the layout with shapes and whatnot to implement it, it's equally good

I'm not sure where to start and how to do it. I'm using PyQt5 and Python 3.6.

Upvotes: 0

Views: 577

Answers (1)

musicamante
musicamante

Reputation: 48399

It can be done with custom QWidgets, but you'll need to do by yourself painting and mouseEvent implementations.
A simpler solution is to use stylesheet and some clever positioning.

Screenshot of the running example

QGridLayout allows to place more than one widget at the same coordinates, even with overlapping widgets within the grid. While that's not possible using QDesigner, it can easily be done programmatically.
This allows to create a frame around the widget, while keeping the correct alignment between buttons and axis labels, some thing that would be a bit harder to achieve by using a single widgets for the grid and the labels.
Just ensure that the frame is under the other widgets (otherwise it will block all mouse events), which can be done by adding the frame before everything else, or by using widget.lower() if it's added afterwards.
Another possibility is to set the Qt.WA_TransparentForMouseEvents as a widget attribute.

To ensure that some margin is placed between the frame and the buttons, I added the frame to the layout starting from column/row 1 using a span of width/height + 2 (grid size + grid slots for left and right), with buttons added starting from column/row 2 (heading + "margin"). If you want a bigger margin, use setColumnMinimumWidth(1, minWidth) and setRowMinimumHeight(1, minHeight), and then apply the same minWidth/minHeight to the last row and column of the QGridLayout to keep it graphically consistent.

import sys
from PyQt5 import QtCore,QtWidgets

class CircleButton(QtWidgets.QPushButton):
    def __init__(self, row, column, size=20):
        QtWidgets.QPushButton.__init__(self)
        self.coords = row, column
        self.setCheckable(True)
        self.setFixedSize(size, size)
        self.setStyleSheet('''
            QPushButton {{
                background: transparent;
                border: 2px solid gray;
                border-radius: {};
            }}
            QPushButton:hover {{
                background: orange;
            }}
            QPushButton:checked {{
                background: red;
            }}
        '''.format(size / 2))

class GridButtons(QtWidgets.QWidget):
    clicked = QtCore.pyqtSignal(str, int)
    shown = False

    def __init__(self):
        QtWidgets.QWidget.__init__(self)
        layout = QtWidgets.QGridLayout()
        self.setLayout(layout)

        self.frame = QtWidgets.QFrame()
        layout.addWidget(self.frame, 1, 1, 10, 14)

        for row, letter in enumerate(' ABCDEFGH'):
            for column in range(13):
                if column == 0 and letter.strip():
                    label = QtWidgets.QLabel(letter)
                    label.setAlignment(QtCore.Qt.AlignCenter)
                    layout.addWidget(label, row + 1, column)
                else:
                    if row == 0:
                        if column:
                            label = QtWidgets.QLabel(str(column))
                            label.setAlignment(QtCore.Qt.AlignCenter)
                            layout.addWidget(label, row, column + 1)
                    else:
                        button = CircleButton(letter, column, 30)
                        button.clicked.connect(lambda status, l=letter, c=column: self.showClicked(l, c, status)))
                        layout.addWidget(button, row + 1, column + 1)

        self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)

        # the dot before QFrame is to ensure that only the frame has a
        # border and not the labels, as QLabel is a QFrame descendant
        self.setStyleSheet('''
            .QFrame {
                background: transparent;
                border: 2px solid gray;
                border-radius: 10;
            }
            QLabel {
                font-weight: bold;
                font-size: 20px;
                color: gray;
            }
        ''')

    def showEvent(self, event):
        if not self.shown and self.window() == self:
            self.shown = True
            self.setFixedSize(self.size())

    def showClicked(self, row, column,status):
        print('Button {}{} is {}!'.format(row, column, ('off', 'on')[status]))


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    w = GridButtons()
    w.show()
    sys.exit(app.exec_())

The only drawback with this is that buttons have a fixed size, so if the widget is resized you'll see that there will be some blank space around the buttons, that's why I've set the QSizePolicy in order to avoid that.

A (complicated) workaround would be to implement the resizeEvent of the main widget, compute the available space for the buttons and use the minimum value between available width and height, then move the setFixedSize() and setStyleSheet() to a separate method of the buttons that computes again their size to avoid rounded rectangle shapes.
Be very careful with that, though, as changing sizes of child widgets within a resizeEvent might result in infinite recursion if you're not computing the right size.

Upvotes: 1

Related Questions