Is there some simple way in PyQt5 to get real dimensions of the pixmap displayed in QLabel? I am trying to select part of the image with rubber band. But I can't find a way to limit the rubberband only to pixmap. The QLabel.pixmap().rect() returns dimensions of the whole QLabel not only the pixmap. The problem arises when the pixmap is scaled and there are stripes on the sides of the picture.
The Example image
Example image 2
I posted are quite self explanatory. I don't want the rubberband to be able to move out of the picture to the white stripes.
class ResizableRubberBand(QWidget):
def __init__(self, parent=None):
super(ResizableRubberBand, self).__init__(parent)
self.aspect_ratio = None
self.setWindowFlags(Qt.SubWindow)
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.grip1 = QSizeGrip(self)
self.grip2 = QSizeGrip(self)
self.layout.addWidget(self.grip1, 0, Qt.AlignLeft | Qt.AlignTop)
self.layout.addWidget(self.grip2, 0, Qt.AlignRight | Qt.AlignBottom)
self.rubberband = QRubberBand(QRubberBand.Rectangle, self)
self.rubberband.setStyle(QStyleFactory.create("Fusion"))
self.rubberband.move(0, 0)
self.rubberband.show()
self.show()
class ResizablePixmap(QLabel):
def __init__(self, bytes_image):
QLabel.__init__(self)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.setAlignment(Qt.AlignVCenter | Qt.AlignHCenter)
self.setStyleSheet("background-color:#ffffff;")
self.update_pixmap(bytes_image)
def resizeEvent(self, event):
if event:
x = event.size().width()
y = event.size().height()
else:
x = self.width()
y = self.height()
self.current_pixmap = self._bytes2pixmap(self.bytes_image_edit)
self.setPixmap(self.current_pixmap.scaled(x, y, Qt.KeepAspectRatio))
self.resize(x, y)
def update_pixmap(self, bytes_image):
self.bytes_image_edit = bytes_image
self.current_pixmap = self._bytes2pixmap(bytes_image)
self.setPixmap(self.current_pixmap)
self.resizeEvent(None)
#staticmethod
def _bytes2pixmap(raw_image):
image = QImage()
image.loadFromData(raw_image)
return QPixmap(image)
#staticmethod
def _pixmap2bytes(pixmap):
byte_array = QByteArray()
buffer = QBuffer(byte_array)
buffer.open(QIODevice.WriteOnly)
pixmap.save(buffer, 'PNG')
return byte_array.data()
#property
def image_dims(self):
return self.width(), self.height()
def force_resize(self, qsize):
self.resizeEvent(QResizeEvent(qsize, qsize))
class SelectablePixmap(ResizablePixmap):
def __init__(self, bytes_image):
super().__init__(bytes_image)
self.currentQRubberBand = None
self.move_rubber_band = False
self.rubber_band_offset = None
def cancel_selection(self):
self.currentQRubberBand.hide()
self.currentQRubberBand.deleteLater()
self.currentQRubberBand = None
self.selectionActive.emit(False)
def mousePressEvent(self, eventQMouseEvent):
if not self.currentQRubberBand:
self.currentQRubberBand = ResizableRubberBand(self)
self.selectionActive.emit(True)
if self.currentQRubberBand.geometry().contains(eventQMouseEvent.pos()):
self.move_rubber_band = True
self.rubber_band_offset = (eventQMouseEvent.pos() -
self.currentQRubberBand.pos())
else:
self.originQPoint = eventQMouseEvent.pos()
if self.pixmap().rect().contains(self.originQPoint):
self.currentQRubberBand.setGeometry(QRect(self.originQPoint,
QSize()))
self.currentQRubberBand.show()
def mouseMoveEvent(self, eventQMouseEvent):
if self.move_rubber_band:
pos = eventQMouseEvent.pos() - self.rubber_band_offset
if self.pixmap().rect().contains(pos):
self.currentQRubberBand.move(pos)
else:
rect = QRect(self.originQPoint, eventQMouseEvent.pos())
self.currentQRubberBand.setGeometry(rect.normalized())
def mouseReleaseEvent(self, eventQMouseEvent):
if self.move_rubber_band:
self.move_rubber_band = False
The "easy" answer to your question is that you can get the actual geometry of the QPixmap by moving its QRect. Since you're using center alignment, that's very simple:
pixmap_rect = self.pixmap.rect()
pixmap_rect.moveCenter(self.rect().center())
Unfortunately you can't just use that rectangle with your implementation, mostly because you are not really using a QRubberBand.
The concept of a child rubberband, using size grips for resizing, is clever, but has a lot of limitations.
While QSizeGrips make resizing easier, their behavior can't be easily "restricted": you'll probably end up trying to reimplement resize and resizeEvent (risking recursions), maybe with some tricky and convoluted mouse event checking. Also, you'll never be able to resize that "virtual" rubberband to a size smaller to the sum of the QSizeGrips' sizes, nor to a "negative" selection.
Also, in your code you never resize the actual QRubberBand geometry (but that can be done within the ResizableRubberBand.resizeEvent()).
Finally, even if you haven't implemented the selection resizing after an image resizing, you would have a lot of issues if you did (mostly because of the aforementioned minimum size restrainings).
I think that a better solution is to use a simple QRubberBand and implement its interaction directly from the widget that uses it. This lets you have finer control over it, also allowing complete resize features (not only top left and bottom right corners).
I slightly modified your base class code, as you should avoid any resizing within a resizeEvent() (even if it didn't do anything in your case, since the size argument of resize() was the same) and did unnecessary calls to _bytes2pixmap.
class ResizablePixmap(QLabel):
def __init__(self, bytes_image):
QLabel.__init__(self)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.setAlignment(Qt.AlignCenter)
self.setStyleSheet("background-color: #ffffff;")
self.update_pixmap(bytes_image)
def update_pixmap(self, bytes_image):
self.bytes_image_edit = bytes_image
self.current_pixmap = self._bytes2pixmap(bytes_image)
def scale(self, fromResize=False):
# use a single central method for scaling; there's no need to call it upon
# creation and also resize() won't work anyway in a layout
self.setPixmap(self.current_pixmap.scaled(self.width(), self.height(),
Qt.KeepAspectRatio, Qt.SmoothTransformation))
def resizeEvent(self, event):
super(ResizablePixmap, self).resizeEvent(event)
self.scale(True)
#staticmethod
def _bytes2pixmap(raw_image):
image = QImage()
image.loadFromData(raw_image)
return QPixmap(image)
class SelectablePixmap(ResizablePixmap):
selectionActive = pyqtSignal(bool)
def __init__(self, bytes_image):
super().__init__(bytes_image)
# activate mouse tracking to change cursor on rubberband hover
self.setMouseTracking(True)
self.currentQRubberBand = None
self.rubber_band_offset = None
self.moveDirection = 0
def create_selection(self, pos):
if self.currentQRubberBand:
self.cancel_selection()
self.currentQRubberBand = QRubberBand(QRubberBand.Rectangle, self)
self.currentQRubberBand.setStyle(QStyleFactory.create("Fusion"))
self.currentQRubberBand.setGeometry(pos.x(), pos.y(), 1, 1)
self.currentQRubberBand.show()
self.originQPoint = pos
self.currentQRubberBand.installEventFilter(self)
def cancel_selection(self):
self.currentQRubberBand.hide()
self.currentQRubberBand.deleteLater()
self.currentQRubberBand = None
self.originQPoint = None
self.selectionActive.emit(False)
def scale(self, fromResize=False):
if fromResize and self.currentQRubberBand:
# keep data for rubber resizing, before scaling
oldPixmapRect = self.pixmap().rect()
oldOrigin = self.currentQRubberBand.pos() - self.pixmapRect.topLeft()
super(SelectablePixmap, self).scale()
# assuming that you always align the image in the center, get the current
# pixmap rect and move the rectangle center to the current geometry
self.pixmapRect = self.pixmap().rect()
self.pixmapRect.moveCenter(self.rect().center())
if fromResize and self.currentQRubberBand:
# find the new size ratio based on the previous
xRatio = self.pixmapRect.width() / oldPixmapRect.width()
yRatio = self.pixmapRect.height() / oldPixmapRect.height()
# create a new geometry using 0-rounding for improved accuracy
self.currentQRubberBand.setGeometry(
round(oldOrigin.x() * xRatio, 0) + self.pixmapRect.x(),
round(oldOrigin.y() * yRatio + self.pixmapRect.y(), 0),
round(self.currentQRubberBand.width() * xRatio, 0),
round(self.currentQRubberBand.height() * yRatio, 0))
def updateMargins(self):
# whenever the rubber rectangle geometry changes, create virtual
# rectangles for corners and sides to ease up mouse event checking
rect = self.currentQRubberBand.geometry()
self.rubberTopLeft = QRect(rect.topLeft(), QSize(8, 8))
self.rubberTopRight = QRect(rect.topRight(), QSize(-8, 8)).normalized()
self.rubberBottomRight = QRect(rect.bottomRight(), QSize(-8, -8)).normalized()
self.rubberBottomLeft = QRect(rect.bottomLeft(), QSize(8, -8)).normalized()
self.rubberLeft = QRect(self.rubberTopLeft.bottomLeft(), self.rubberBottomLeft.topRight())
self.rubberTop = QRect(self.rubberTopLeft.topRight(), self.rubberTopRight.bottomLeft())
self.rubberRight = QRect(self.rubberTopRight.bottomLeft(), self.rubberBottomRight.topRight())
self.rubberBottom = QRect(self.rubberBottomLeft.topRight(), self.rubberBottomRight.bottomLeft())
self.rubberInnerRect = QRect(self.rubberTop.bottomLeft(), self.rubberBottom.topRight())
def eventFilter(self, source, event):
if event.type() in (QEvent.Resize, QEvent.Move):
self.updateMargins()
return super(SelectablePixmap, self).eventFilter(source, event)
def mousePressEvent(self, event):
pos = event.pos()
if not self.currentQRubberBand or not pos in self.currentQRubberBand.geometry():
if pos not in self.pixmapRect:
self.originQPoint = None
return
self.create_selection(pos)
elif pos in self.rubberTopLeft:
self.originQPoint = self.currentQRubberBand.geometry().bottomRight()
elif pos in self.rubberTopRight:
self.originQPoint = self.currentQRubberBand.geometry().bottomLeft()
elif pos in self.rubberBottomRight:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
elif pos in self.rubberBottomLeft:
self.originQPoint = self.currentQRubberBand.geometry().topRight()
elif pos in self.rubberTop:
self.originQPoint = self.currentQRubberBand.geometry().bottomLeft()
self.moveDirection = Qt.Vertical
elif pos in self.rubberBottom:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
self.moveDirection = Qt.Vertical
elif pos in self.rubberLeft:
self.originQPoint = self.currentQRubberBand.geometry().topRight()
self.moveDirection = Qt.Horizontal
elif pos in self.rubberRight:
self.originQPoint = self.currentQRubberBand.geometry().topLeft()
self.moveDirection = Qt.Horizontal
else:
self.rubber_band_offset = pos - self.currentQRubberBand.pos()
def mouseMoveEvent(self, event):
pos = event.pos()
if event.buttons() == Qt.NoButton and self.currentQRubberBand:
if pos in self.rubberTopLeft or pos in self.rubberBottomRight:
self.setCursor(Qt.SizeFDiagCursor)
elif pos in self.rubberTopRight or pos in self.rubberBottomLeft:
self.setCursor(Qt.SizeBDiagCursor)
elif pos in self.rubberLeft or pos in self.rubberRight:
self.setCursor(Qt.SizeHorCursor)
elif pos in self.rubberTop or pos in self.rubberBottom:
self.setCursor(Qt.SizeVerCursor)
elif pos in self.rubberInnerRect:
self.setCursor(Qt.SizeAllCursor)
else:
self.unsetCursor()
elif event.buttons():
if self.rubber_band_offset:
target = pos - self.rubber_band_offset
rect = QRect(target, self.currentQRubberBand.size())
# limit positioning of the selection to the image rectangle
if rect.x() < self.pixmapRect.x():
rect.moveLeft(self.pixmapRect.x())
elif rect.right() > self.pixmapRect.right():
rect.moveRight(self.pixmapRect.right())
if rect.y() < self.pixmapRect.y():
rect.moveTop(self.pixmapRect.y())
elif rect.bottom() > self.pixmapRect.bottom():
rect.moveBottom(self.pixmapRect.bottom())
self.currentQRubberBand.setGeometry(rect)
elif self.originQPoint:
if self.moveDirection == Qt.Vertical:
# keep the X fixed to the current right, so that only the
# vertical position is changed
pos.setX(self.currentQRubberBand.geometry().right())
else:
# limit the X to the pixmapRect extent
if pos.x() < self.pixmapRect.x():
pos.setX(self.pixmapRect.x())
elif pos.x() > self.pixmapRect.right():
pos.setX(self.pixmapRect.right())
if self.moveDirection == Qt.Horizontal:
# same as before, but for the Y position
pos.setY(self.currentQRubberBand.geometry().bottom())
else:
# limit the Y to the pixmapRect extent
if pos.y() < self.pixmapRect.y():
pos.setY(self.pixmapRect.y())
elif pos.y() > self.pixmapRect.bottom():
pos.setY(self.pixmapRect.bottom())
rect = QRect(self.originQPoint, pos)
self.currentQRubberBand.setGeometry(rect.normalized())
def mouseReleaseEvent(self, event):
self.rubber_band_offset = None
self.originQPoint = None
self.moveDirection = 0
You could store width and height of the image (before you create the pixmap from bytes) into global variable and then use getter to access it from outside of class.
Related
I have a movable QGraphicsRectItem which is rotated to 90 degrees and set to a scene. When I drag the item, it moves randomly and eventually disappear.
However, when I set the rotation to 0, the item moves flawlessly.
Here is my minimal reproducible example.
class main_window(QWidget):
def __init__(self):
super().__init__()
self.rect = Rectangle(100, 100, 100, 100)
self.rect.setRotation(90)
self.view = QGraphicsView(self)
self.scene = QGraphicsScene(self.view)
self.scene.addItem(self.rect)
self.view.setSceneRect(0, 0, 500,500)
self.view.setScene(self.scene)
self.slider = QSlider(QtCore.Qt.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(90)
vbox = QVBoxLayout(self)
vbox.addWidget(self.view)
vbox.addWidget(self.slider)
self.setLayout(vbox)
self.slider.valueChanged.connect(self.rotate)
def rotate(self, value):
self.angle = int(value)
self.rect.setRotation(self.angle)
class Rectangle(QGraphicsRectItem):
def __init__(self, *args):
super().__init__(*args)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
self.setPen(QPen(QBrush(QtGui.QColor('red')), 5))
self.selected_edge = None
self.first_pos = None
self.click_rect = None
def mousePressEvent(self, event):
self.first_pos = event.pos()
self.rect_shape = self.rect()
self.click_rect = self.rect_shape
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
# Calculate how much the mouse has moved since the click.
self.pos = event.pos()
x_diff = self.pos.x() - self.first_pos.x()
y_diff = self.pos.y() - self.first_pos.y()
# Start with the rectangle as it was when clicked.
self.rect_shape = QtCore.QRectF(self.click_rect)
self.rect_shape.translate(x_diff, y_diff)
self.setRect(self.rect_shape)
self.setTransformOriginPoint(self.rect_shape.center())
(I included a slider at the bottom of the main window to conveniently rotate the item)
Why does this happen?
The issue is caused by various aspects:
setting a QRect at given coordinates while keeping the item at the same default position (0, 0);
changing the rectangle as consequence of a mouse move event;
changing the transformation origin point after that;
the mapping of the mouse coordinates between integer based point (on the screen) and floating (on the scene);
the transformation (rotation);
implementing the item movement without considering the above (while QGraphicsItem already provides it with the ItemIsMovable flag);
Note that while rotation might seem a simple operation, it is achieved by using a combination of two transformations: shearing and scaling; this means that the transformation applies very complex computations that depend on the floating point precision.
This becomes an issue when dealing with integer to floating conversion: the same mouse (integer based) coordinate can be mapped at a very different point depending on the transformation, and the "higher" the transformation is applied, the bigger the difference can be. As a result, the mapped mouse position can be very different, the rectangle is translated to a "wrong" point, and the transformation origin point moves the rectangle "away" by an increasing ratio.
The solution is to completely change the way the rectangle is positioned and actually simplify the reference: the rectangle is always centered at the item position, so that we can keep the transformation origin point at the default (0, 0 in item coordinates).
The only inconvenience with this approach is that the item's pos() will not be on its top left corner anymore, but that is not a real issue: when the item is rotated, its top left corner would not be at that position anyway.
If you need to know the actual position of the item, you can then translate the rectangle based on the item scene position.
If you want to position the rectangle based on its top left corner, you have to map the position from the scene and compute the delta of the reference point (the actual top left corner).
I took the liberty of taking your previous question, which implemented the resizing, and improving it also to better show how the solution works.
class Selection(QtWidgets.QGraphicsRectItem):
Left, Top, Right, Bottom = 1, 2, 4, 8
def __init__(self, *args):
rect = QtCore.QRectF(*args)
pos = rect.center()
# move the center of the rectangle to 0, 0
rect.translate(-rect.center())
super().__init__(rect)
self.setPos(pos)
self.setPen(QtGui.QPen(QtCore.Qt.red, 5))
self.setFlags(
self.ItemIsMovable |
self.ItemIsSelectable |
self.ItemSendsGeometryChanges
)
def mapRect(self):
return QtCore.QRectF(
self.mapToScene(self.rect().topLeft()),
self.rect().size()
)
def setRectPosition(self, pos):
localPos = self.mapFromScene(pos)
delta = self.rect().topLeft() - localPos
self.setPos(self.pos() + delta)
def itemChange(self, change, value):
if change in (self.ItemPositionHasChanged, self.ItemRotationHasChanged):
print(self.mapRect())
return super().itemChange(change, value)
def mousePressEvent(self, event):
super().mousePressEvent(event)
pos = event.pos()
rect = self.rect()
margin = self.pen().width() / 2
self.anchor = 0
if pos.x() <= rect.x() + margin:
self.anchor |= self.Left
elif pos.x() >= rect.right() - margin:
self.anchor |= self.Right
if pos.y() <= rect.y() + margin:
self.anchor |= self.Top
elif pos.y() >= rect.bottom() - margin:
self.anchor |= self.Bottom
if self.anchor:
self.clickAngle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if not self.anchor:
super().mouseMoveEvent(event)
return
rect = self.rect()
pos = event.pos()
if self.anchor == self.Left:
rect.setLeft(pos.x())
elif self.anchor == self.Right:
rect.setRight(pos.x())
elif self.anchor == self.Top:
rect.setTop(pos.y())
elif self.anchor == self.Bottom:
rect.setBottom(pos.y())
else:
# clicked on a corner, let's rotate
angle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
rotation = max(0, min(90, self.rotation() + self.clickAngle - angle))
self.setRotation(rotation)
return
pos = self.mapToScene(rect.center())
self.setPos(pos)
rect.moveCenter(QtCore.QPointF())
self.setRect(rect)
I wanted to ask if it is possible to use only the Color Gradient(red surrounded) part of the QColorDialog.
I am using PyQt5 and Python3 on different Linux machines (ubuntu + raspian).
It is only necessary to hide all the elements except the QColorPicker and QColorLuminancePicker.
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
class ColorDialog(QtWidgets.QColorDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setOptions(self.options() | QtWidgets.QColorDialog.DontUseNativeDialog)
for children in self.findChildren(QtWidgets.QWidget):
classname = children.metaObject().className()
if classname not in ("QColorPicker", "QColorLuminancePicker"):
children.hide()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
colordialog = ColorDialog()
label = QtWidgets.QLabel(alignment=QtCore.Qt.AlignCenter)
def onCurrentColorChanged(color):
label.setStyleSheet("background-color: {}".format(color.name()))
colordialog.currentColorChanged.connect(onCurrentColorChanged)
onCurrentColorChanged(colordialog.currentColor())
w = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(w)
lay.addWidget(colordialog, alignment=QtCore.Qt.AlignCenter)
lay.addWidget(label)
w.show()
sys.exit(app.exec_())
The solution proposed by eyllanesc is perfect (and smart) as always.
Nonetheless, some months ago I had to implement my own color picker widget, which creates the "color rainbow" widget only from code, and I'd like to share part of that code as I believe it could be of some interest, especially regarding the creation of the "rainbow" and the color detection.
I've edited it down to make it a basic usable "color picker" widget.
from PyQt5 import QtCore, QtGui, QtWidgets
# a helper function that ensures that a value is between the given range
sanitize = lambda m, value, M: min(max(m, value), M)
class RgbPicker(QtWidgets.QLabel):
colorGrads = QtGui.QLinearGradient(0, 0, 1, 0)
colorGrads.setCoordinateMode(colorGrads.ObjectBoundingMode)
xRatio = 1. / 6
# the basic "rainbow" gradient
colorGrads.setColorAt(0, QtCore.Qt.red)
colorGrads.setColorAt(1, QtCore.Qt.red)
colorGrads.setColorAt(xRatio, QtCore.Qt.magenta)
colorGrads.setColorAt(xRatio * 2, QtCore.Qt.blue)
colorGrads.setColorAt(xRatio * 3, QtCore.Qt.cyan)
colorGrads.setColorAt(xRatio * 4, QtCore.Qt.green)
colorGrads.setColorAt(xRatio * 5, QtCore.Qt.yellow)
# the superimposed white component
maskGrad = QtGui.QLinearGradient(0, 0, 0, 1)
maskGrad.setCoordinateMode(maskGrad.ObjectBoundingMode)
maskGrad.setColorAt(0, QtCore.Qt.transparent)
maskGrad.setColorAt(1, QtCore.Qt.white)
# the pseudo arrow cursor
cursorPath = QtGui.QPainterPath()
cursorPath.moveTo(-10, 0)
cursorPath.lineTo(-4, 0)
cursorPath.moveTo(0, -10)
cursorPath.lineTo(0, -4)
cursorPath.moveTo(4, 0)
cursorPath.lineTo(10, 0)
cursorPath.moveTo(0, 4)
cursorPath.lineTo(0, 10)
cursorPen = QtGui.QPen(QtCore.Qt.black, 3)
colorChanged = QtCore.pyqtSignal(QtGui.QColor)
def __init__(self, color=None):
QtWidgets.QLabel.__init__(self)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setFixedSize(220, 200)
# create a pixmap that shows the "rainbow" and draw its contents
pixmap = QtGui.QPixmap(self.size())
qp = QtGui.QPainter(pixmap)
qp.fillRect(pixmap.rect(), self.colorGrads)
qp.fillRect(pixmap.rect(), self.maskGrad)
qp.end()
self.setPixmap(pixmap)
self.image = pixmap.toImage()
# a multiplier, used when an arrow key is kept pressed
self.keyTimer = QtCore.QTimer()
self.keyTimer.setInterval(1000)
self.keyTimer.timeout.connect(lambda: setattr(self, 'keyMulti', self.keyMulti + 1))
self._cursorPos = QtCore.QPoint()
self._color = color
self.setColor(color)
#property
def color(self):
return self._color
#property
def cursorPos(self):
return self._cursorPos
#cursorPos.setter
def cursorPos(self, pos):
self._cursorPos = pos
self.update()
def sanitizePos(self, pos):
# sanitize the position within the color rainbow margins
return QtCore.QPoint(sanitize(0, pos.x(), self.width() - 1), sanitize(0, pos.y(), self.height() - 1))
def setColor(self, color):
h, s, v, a = color.getHsv()
# compute the coordinates based on hsv components
x = (360 - h) * (self.width() - 1) / 360.
y = (255 - s) * (self.height() - 1) / 255.
self.cursorPos = QtCore.QPoint(x, y)
def translateColorCursor(self, pos):
# return the color of the given pixel
return QtGui.QColor(self.image.pixel(pos))
def mousePressEvent(self, event):
pos = self.sanitizePos(event.pos())
self._color = self.translateColorCursor(pos)
self.colorChanged.emit(QtGui.QColor(self._color))
self.cursorPos = pos
def mouseMoveEvent(self, event):
pos = self.sanitizePos(event.pos())
self._color = self.translateColorCursor(pos)
self.colorChanged.emit(QtGui.QColor(self._color))
self.cursorPos = pos
def keyPressEvent(self, event):
deltaX = deltaY = 0
# emulation of the Qt internal color picker keyboard navigation
if event.modifiers() & QtCore.Qt.KeypadModifier:
if event.key() in (QtCore.Qt.Key_7, QtCore.Qt.Key_Home):
deltaX = deltaY = -1
elif event.key() in (QtCore.Qt.Key_8, QtCore.Qt.Key_Up):
deltaY = -1
elif event.key() in (QtCore.Qt.Key_9, QtCore.Qt.Key_PageUp):
deltaX = 1
deltaY = -1
elif event.key() in (QtCore.Qt.Key_4, QtCore.Qt.Key_Left):
deltaX = -1
elif event.key() in (QtCore.Qt.Key_6, QtCore.Qt.Key_Right):
deltaX = 1
elif event.key() in (QtCore.Qt.Key_1, QtCore.Qt.Key_End):
deltaX = -1
deltaY = 1
elif event.key() in (QtCore.Qt.Key_2, QtCore.Qt.Key_Down):
deltaY = 1
elif event.key() in (QtCore.Qt.Key_3, QtCore.Qt.Key_PageDown):
deltaX = deltaY = 1
elif event.key() == QtCore.Qt.Key_Left:
deltaX = -1
elif event.key() == QtCore.Qt.Key_Right:
deltaX = 1
elif event.key() == QtCore.Qt.Key_Up:
deltaY = -1
elif event.key() == QtCore.Qt.Key_Down:
deltaY = 1
elif event.key() == QtCore.Qt.Key_Home:
if event.modifiers() == QtCore.Qt.ShiftModifier:
deltaY = -1000
elif event.modifiers() == QtCore.Qt.ControlModifier:
deltaX = deltaY = -1000
else:
deltaX = -1000
elif event.key() == QtCore.Qt.Key_End:
if event.modifiers() == QtCore.Qt.ShiftModifier:
deltaY = 1000
elif event.modifiers() == QtCore.Qt.ControlModifier:
deltaX = deltaY = 1000
else:
deltaX = 1000
elif event.key() == QtCore.Qt.Key_PageUp and not event.modifiers() & QtCore.Qt.KeypadModifier:
deltaY = -10
elif event.key() == QtCore.Qt.Key_PageDown and not event.modifiers() & QtCore.Qt.KeypadModifier:
deltaY = +10
else:
return QtWidgets.QWidget.keyPressEvent(self, event)
if not event.isAutoRepeat():
self.keyTimer.start()
self.keyMulti = 1
if deltaX or deltaY:
multi = self.keyMulti
if event.modifiers() & QtCore.Qt.ShiftModifier:
multi *= 10
deltaX *= multi
deltaY *= multi
pos = self.sanitizePos(QtCore.QPoint(self.cursorPos.x() + deltaX, self.cursorPos.y() + deltaY))
self._color = self.translateColorCursor(pos)
self.colorChanged.emit(QtGui.QColor(self._color))
self.cursorPos = pos
def keyReleaseEvent(self, event):
if not event.isAutoRepeat():
self.keyTimer.stop()
QtWidgets.QWidget.keyReleaseEvent(self, event)
def paintEvent(self, event):
QtWidgets.QLabel.paintEvent(self, event)
qp = QtGui.QPainter(self)
qp.setPen(self.cursorPen)
# translate to the crosshair position and paint it
qp.translate(self.cursorPos)
qp.drawPath(self.cursorPath)
class BlackPicker(QtWidgets.QWidget):
# the "black" selector, which has a gradient based on the current selected
# color of the RgbPicker (selected color -> black)
_rect = QtCore.QRect(0, 8, 16, 200)
grad = QtGui.QLinearGradient(0, 0, 0, 1)
grad.setCoordinateMode(grad.ObjectBoundingMode)
grad.setColorAt(1, QtCore.Qt.black)
arrow = QtGui.QPainterPath()
arrow.lineTo(4, -4)
arrow.lineTo(4, 4)
arrow.closeSubpath()
_color = QtGui.QColor()
_black = -1
blackChanged = QtCore.pyqtSignal(float)
def __init__(self, color):
QtWidgets.QWidget.__init__(self)
self.color = QtGui.QColor(color)
self.setFixedSize(22, 216)
#property
def black(self):
return self._black
#black.setter
def black(self, black):
if black == self._black:
return
self._black = black
self.update()
self.blackChanged.emit(black)
#property
def color(self):
return self._color
#color.setter
def color(self, color):
if color == self._color:
return
self._color = QtGui.QColor(color)
self.grad.setColorAt(0, color)
self.black = color.getCmykF()[3]
def setWhiteColor(self, color):
self.grad.setColorAt(0, color)
self.update()
def setColor(self, color):
self.color = color
def mousePressEvent(self, event):
self.black = sanitize(0, event.pos().y() - self._rect.top(),
self._rect.height()) / 200.
def mouseMoveEvent(self, event):
self.black = sanitize(0, event.pos().y() - self._rect.top(),
self._rect.height()) / 200.
def wheelEvent(self, event):
if event.pixelDelta().y() < 0:
delta = .01
else:
delta = -.01
self.black = sanitize(0, self.black + delta, 1)
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setRenderHints(qp.Antialiasing)
qp.fillRect(self._rect, self.grad)
qp.translate(self._rect.right() + 2, self._rect.top() + self.black * self._rect.height())
qp.setBrush(QtCore.Qt.black)
qp.translate(.5, .5)
qp.drawPath(self.arrow)
class ColorPicker(QtWidgets.QWidget):
colorChanged = QtCore.pyqtSignal(QtGui.QColor)
def __init__(self, color=None, parent=None):
super().__init__(parent)
layout = QtWidgets.QGridLayout(self)
if not (isinstance(color, QtGui.QColor) and color.isValid()):
if isinstance(color, QtCore.Qt.GlobalColor):
color = QtGui.QColor(color)
else:
color = self.palette().color(QtGui.QPalette.WindowText)
self.rgbPicker = RgbPicker(color)
layout.addWidget(self.rgbPicker, 0, 0)
self.blackPicker = BlackPicker(color)
layout.addWidget(self.blackPicker, 0, 1)
self.colorWidget = QtWidgets.QWidget()
layout.addWidget(self.colorWidget, 1, 0, 1, 2)
self.colorWidget.setMinimumHeight(16)
self.colorWidget.setAutoFillBackground(True)
self.colorLabel = QtWidgets.QLabel()
layout.addWidget(self.colorLabel)
self.rgbPicker.colorChanged.connect(self.updateColor)
self.rgbPicker.colorChanged.connect(self.blackPicker.setWhiteColor)
self.blackPicker.blackChanged.connect(self.updateColor)
self.updateColor()
def updateColor(self):
color = self.rgbPicker.color
c, m, y, _, _ = color.getCmykF()
color.setCmykF(c, m, y, self.blackPicker.black)
palette = self.colorWidget.palette()
palette.setColor(palette.Window, color)
self.colorWidget.setPalette(palette)
r, g, b = color.getRgb()[:-1]
hexColor = '{:02X}{:02X}{:02X}'.format(r, g, b)
self.colorLabel.setText('R:{:03} G:{:03} B:{:03} - #{}'.format(
r, g, b, hexColor))
self.colorChanged.emit(color)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
picker = ColorPicker(QtGui.QColor(QtCore.Qt.white))
picker.show()
sys.exit(app.exec())
You can check out this Circular Color Picker design for PyQt
PyQt Circular ColorPicker
It looks like this and can be easily modified
I'm trying to create a slider with option for range selection using wxSlider in Python. It has an optional range parameter but the problem is:
SL_SELRANGE: Allows the user to select a range on the slider. Windows only.
And I'm using Linux.
I thought I might subclass wxSlider and make it work on Linux, or create a custom widget on my own. The problem is I'm not sure how to go about either option.
Any ideas/pointers/pointing me in the right direction would be appreciated.
I tried something like:
range_slider = wx.Slider(parent, wx.ID_ANY, 0, 0, 100, style=wx.SL_HORIZONTAL | wx.SL_LABELS | wx.SL_SELRANGE)
but the "SL_SELRANGE" does nothing on Linux (should provide two handles, to select range).
I'm aware this question is several years old, but even if it's too late to help you, it might help others, as I was going through the same problem recently.
Problem
Even in Windows, the wx.SL_SELRANGE style does not behave as one would expect, creating two independent "thumbs" or handles, which would allow the user to select a range (see this similar question and the documentation). Instead, what it actually does is draw a static band in the trackbar, which does not interact with the single user-controlled thumb. To my knowledge it is not possible to customize the existing wx.Slider control to have two thumbs, since the control is native to the OS.
Solution
In an app I was building I needed to use a control that does what you wanted, but also could not find any good alternatives online. What I ended up doing is creating my own custom RangeSlider widget, which mimics the behavior and functionality of a regular wx.Slider, but with two thumbs:
Notice however that the RangeSlider class handles all the graphics rendering itself and I made it to mimic the Windows 10 look. Therefore the slider appearance will not match the style of a different OS, but it should still work in Linux or OSX. If necessary you could customize the appearance by changing the colors and shapes (all I do is draw rectangles and polygons).
There are some limitations to the widget, it doesn't currently support styles (no ticks or vertical sliders, for example) or validators, but I did implement the wx.EVT_SLIDER event, so other controls can be notified if the values change (this is what I use to dynamically update the text with the slider values, as the user moves the thumbs).
You can find below the code for a working example (it is also available in this GitHub gist, where I may make improvements over time).
import wx
def fraction_to_value(fraction, min_value, max_value):
return (max_value - min_value) * fraction + min_value
def value_to_fraction(value, min_value, max_value):
return float(value - min_value) / (max_value - min_value)
class SliderThumb:
def __init__(self, parent, value):
self.parent = parent
self.dragged = False
self.mouse_over = False
self.thumb_poly = ((0, 0), (0, 13), (5, 18), (10, 13), (10, 0))
self.thumb_shadow_poly = ((0, 14), (4, 18), (6, 18), (10, 14))
min_coords = [float('Inf'), float('Inf')]
max_coords = [-float('Inf'), -float('Inf')]
for pt in list(self.thumb_poly) + list(self.thumb_shadow_poly):
for i_coord, coord in enumerate(pt):
if coord > max_coords[i_coord]:
max_coords[i_coord] = coord
if coord < min_coords[i_coord]:
min_coords[i_coord] = coord
self.size = (max_coords[0] - min_coords[0],
max_coords[1] - min_coords[1])
self.value = value
self.normal_color = wx.Colour((0, 120, 215))
self.normal_shadow_color = wx.Colour((120, 180, 228))
self.dragged_color = wx.Colour((204, 204, 204))
self.dragged_shadow_color = wx.Colour((222, 222, 222))
self.mouse_over_color = wx.Colour((23, 23, 23))
self.mouse_over_shadow_color = wx.Colour((132, 132, 132))
def GetPosition(self):
min_x = self.GetMin()
max_x = self.GetMax()
parent_size = self.parent.GetSize()
min_value = self.parent.GetMin()
max_value = self.parent.GetMax()
fraction = value_to_fraction(self.value, min_value, max_value)
pos = (fraction_to_value(fraction, min_x, max_x), parent_size[1] / 2 + 1)
return pos
def SetPosition(self, pos):
pos_x = pos[0]
# Limit movement by the position of the other thumb
who_other, other_thumb = self.GetOtherThumb()
other_pos = other_thumb.GetPosition()
if who_other == 'low':
pos_x = max(other_pos[0] + other_thumb.size[0]/2 + self.size[0]/2, pos_x)
else:
pos_x = min(other_pos[0] - other_thumb.size[0]/2 - self.size[0]/2, pos_x)
# Limit movement by slider boundaries
min_x = self.GetMin()
max_x = self.GetMax()
pos_x = min(max(pos_x, min_x), max_x)
fraction = value_to_fraction(pos_x, min_x, max_x)
self.value = fraction_to_value(fraction, self.parent.GetMin(), self.parent.GetMax())
# Post event notifying that position changed
self.PostEvent()
def GetValue(self):
return self.value
def SetValue(self, value):
self.value = value
# Post event notifying that value changed
self.PostEvent()
def PostEvent(self):
event = wx.PyCommandEvent(wx.EVT_SLIDER.typeId, self.parent.GetId())
event.SetEventObject(self.parent)
wx.PostEvent(self.parent.GetEventHandler(), event)
def GetMin(self):
min_x = self.parent.border_width + self.size[0] / 2
return min_x
def GetMax(self):
parent_size = self.parent.GetSize()
max_x = parent_size[0] - self.parent.border_width - self.size[0] / 2
return max_x
def IsMouseOver(self, mouse_pos):
in_hitbox = True
my_pos = self.GetPosition()
for i_coord, mouse_coord in enumerate(mouse_pos):
boundary_low = my_pos[i_coord] - self.size[i_coord] / 2
boundary_high = my_pos[i_coord] + self.size[i_coord] / 2
in_hitbox = in_hitbox and (boundary_low <= mouse_coord <= boundary_high)
return in_hitbox
def GetOtherThumb(self):
if self.parent.thumbs['low'] != self:
return 'low', self.parent.thumbs['low']
else:
return 'high', self.parent.thumbs['high']
def OnPaint(self, dc):
if self.dragged or not self.parent.IsEnabled():
thumb_color = self.dragged_color
thumb_shadow_color = self.dragged_shadow_color
elif self.mouse_over:
thumb_color = self.mouse_over_color
thumb_shadow_color = self.mouse_over_shadow_color
else:
thumb_color = self.normal_color
thumb_shadow_color = self.normal_shadow_color
my_pos = self.GetPosition()
# Draw thumb shadow (or anti-aliasing effect)
dc.SetBrush(wx.Brush(thumb_shadow_color, style=wx.BRUSHSTYLE_SOLID))
dc.SetPen(wx.Pen(thumb_shadow_color, width=1, style=wx.PENSTYLE_SOLID))
dc.DrawPolygon(points=self.thumb_shadow_poly,
xoffset=my_pos[0] - self.size[0]/2,
yoffset=my_pos[1] - self.size[1]/2)
# Draw thumb itself
dc.SetBrush(wx.Brush(thumb_color, style=wx.BRUSHSTYLE_SOLID))
dc.SetPen(wx.Pen(thumb_color, width=1, style=wx.PENSTYLE_SOLID))
dc.DrawPolygon(points=self.thumb_poly,
xoffset=my_pos[0] - self.size[0] / 2,
yoffset=my_pos[1] - self.size[1] / 2)
class RangeSlider(wx.Panel):
def __init__(self, parent, id=wx.ID_ANY, lowValue=None, highValue=None, minValue=0, maxValue=100,
pos=wx.DefaultPosition, size=wx.DefaultSize, style=wx.SL_HORIZONTAL, validator=wx.DefaultValidator,
name='rangeSlider'):
if style != wx.SL_HORIZONTAL:
raise NotImplementedError('Styles not implemented')
if validator != wx.DefaultValidator:
raise NotImplementedError('Validator not implemented')
super().__init__(parent=parent, id=id, pos=pos, size=size, name=name)
self.SetMinSize(size=(max(50, size[0]), max(26, size[1])))
if minValue > maxValue:
minValue, maxValue = maxValue, minValue
self.min_value = minValue
self.max_value = maxValue
if lowValue is None:
lowValue = self.min_value
if highValue is None:
highValue = self.max_value
if lowValue > highValue:
lowValue, highValue = highValue, lowValue
lowValue = max(lowValue, self.min_value)
highValue = min(highValue, self.max_value)
self.border_width = 8
self.thumbs = {
'low': SliderThumb(parent=self, value=lowValue),
'high': SliderThumb(parent=self, value=highValue)
}
self.thumb_width = self.thumbs['low'].size[0]
# Aesthetic definitions
self.slider_background_color = wx.Colour((231, 234, 234))
self.slider_outline_color = wx.Colour((214, 214, 214))
self.selected_range_color = wx.Colour((0, 120, 215))
self.selected_range_outline_color = wx.Colour((0, 120, 215))
# Bind events
self.Bind(wx.EVT_LEFT_DOWN, self.OnMouseDown)
self.Bind(wx.EVT_LEFT_UP, self.OnMouseUp)
self.Bind(wx.EVT_MOTION, self.OnMouseMotion)
self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self.OnMouseLost)
self.Bind(wx.EVT_ENTER_WINDOW, self.OnMouseEnter)
self.Bind(wx.EVT_LEAVE_WINDOW, self.OnMouseLeave)
self.Bind(wx.EVT_PAINT, self.OnPaint)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
self.Bind(wx.EVT_SIZE, self.OnResize)
def Enable(self, enable=True):
super().Enable(enable)
self.Refresh()
def Disable(self):
super().Disable()
self.Refresh()
def SetValueFromMousePosition(self, click_pos):
for thumb in self.thumbs.values():
if thumb.dragged:
thumb.SetPosition(click_pos)
def OnMouseDown(self, evt):
if not self.IsEnabled():
return
click_pos = evt.GetPosition()
for thumb in self.thumbs.values():
if thumb.IsMouseOver(click_pos):
thumb.dragged = True
thumb.mouse_over = False
break
self.SetValueFromMousePosition(click_pos)
self.CaptureMouse()
self.Refresh()
def OnMouseUp(self, evt):
if not self.IsEnabled():
return
self.SetValueFromMousePosition(evt.GetPosition())
for thumb in self.thumbs.values():
thumb.dragged = False
if self.HasCapture():
self.ReleaseMouse()
self.Refresh()
def OnMouseLost(self, evt):
for thumb in self.thumbs.values():
thumb.dragged = False
thumb.mouse_over = False
self.Refresh()
def OnMouseMotion(self, evt):
if not self.IsEnabled():
return
refresh_needed = False
mouse_pos = evt.GetPosition()
if evt.Dragging() and evt.LeftIsDown():
self.SetValueFromMousePosition(mouse_pos)
refresh_needed = True
else:
for thumb in self.thumbs.values():
old_mouse_over = thumb.mouse_over
thumb.mouse_over = thumb.IsMouseOver(mouse_pos)
if old_mouse_over != thumb.mouse_over:
refresh_needed = True
if refresh_needed:
self.Refresh()
def OnMouseEnter(self, evt):
if not self.IsEnabled():
return
mouse_pos = evt.GetPosition()
for thumb in self.thumbs.values():
if thumb.IsMouseOver(mouse_pos):
thumb.mouse_over = True
self.Refresh()
break
def OnMouseLeave(self, evt):
if not self.IsEnabled():
return
for thumb in self.thumbs.values():
thumb.mouse_over = False
self.Refresh()
def OnResize(self, evt):
self.Refresh()
def OnPaint(self, evt):
w, h = self.GetSize()
# BufferedPaintDC should reduce flickering
dc = wx.BufferedPaintDC(self)
background_brush = wx.Brush(self.GetBackgroundColour(), wx.SOLID)
dc.SetBackground(background_brush)
dc.Clear()
# Draw slider
track_height = 12
dc.SetPen(wx.Pen(self.slider_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.slider_background_color, style=wx.BRUSHSTYLE_SOLID))
dc.DrawRectangle(self.border_width, h/2 - track_height/2, w - 2 * self.border_width, track_height)
# Draw selected range
if self.IsEnabled():
dc.SetPen(wx.Pen(self.selected_range_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.selected_range_color, style=wx.BRUSHSTYLE_SOLID))
else:
dc.SetPen(wx.Pen(self.slider_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.slider_outline_color, style=wx.BRUSHSTYLE_SOLID))
low_pos = self.thumbs['low'].GetPosition()[0]
high_pos = self.thumbs['high'].GetPosition()[0]
dc.DrawRectangle(low_pos, h / 2 - track_height / 4, high_pos - low_pos, track_height / 2)
# Draw thumbs
for thumb in self.thumbs.values():
thumb.OnPaint(dc)
evt.Skip()
def OnEraseBackground(self, evt):
# This should reduce flickering
pass
def GetValues(self):
return self.thumbs['low'].value, self.thumbs['high'].value
def SetValues(self, lowValue, highValue):
if lowValue > highValue:
lowValue, highValue = highValue, lowValue
lowValue = max(lowValue, self.min_value)
highValue = min(highValue, self.max_value)
self.thumbs['low'].SetValue(lowValue)
self.thumbs['high'].SetValue(highValue)
self.Refresh()
def GetMax(self):
return self.max_value
def GetMin(self):
return self.min_value
def SetMax(self, maxValue):
if maxValue < self.min_value:
maxValue = self.min_value
_, old_high = self.GetValues()
if old_high > maxValue:
self.thumbs['high'].SetValue(maxValue)
self.max_value = maxValue
self.Refresh()
def SetMin(self, minValue):
if minValue > self.max_value:
minValue = self.max_value
old_low, _ = self.GetValues()
if old_low < minValue:
self.thumbs['low'].SetValue(minValue)
self.min_value = minValue
self.Refresh()
class TestFrame(wx.Frame):
def __init__(self):
wx.Frame.__init__(self, None, -1, 'Range Slider Demo', size=(300, 100))
panel = wx.Panel(self)
b = 6
vbox = wx.BoxSizer(orient=wx.VERTICAL)
vbox.Add(wx.StaticText(parent=panel, label='Custom Range Slider:'), flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.rangeslider = RangeSlider(parent=panel, lowValue=20, highValue=80, minValue=0, maxValue=100,
size=(300, 26))
self.rangeslider.Bind(wx.EVT_SLIDER, self.rangeslider_changed)
vbox.Add(self.rangeslider, proportion=1, flag=wx.EXPAND | wx.ALL, border=b)
self.rangeslider_static = wx.StaticText(panel)
vbox.Add(self.rangeslider_static, flag=wx.ALIGN_LEFT | wx.ALL, border=b)
vbox.Add(wx.StaticText(parent=panel, label='Regular Slider with wx.SL_SELRANGE style:'),
flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.slider = wx.Slider(parent=panel, style=wx.SL_SELRANGE)
self.slider.SetSelection(20, 40)
self.slider.Bind(wx.EVT_SLIDER, self.slider_changed)
vbox.Add(self.slider, proportion=1, flag=wx.EXPAND | wx.ALL, border=b)
self.slider_static = wx.StaticText(panel)
vbox.Add(self.slider_static, flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.button_toggle = wx.Button(parent=panel, label='Disable')
self.button_toggle.Bind(wx.EVT_BUTTON, self.toggle_slider_enable)
vbox.Add(self.button_toggle, flag=wx.ALIGN_CENTER | wx.ALL, border=b)
panel.SetSizerAndFit(vbox)
box = wx.BoxSizer()
box.Add(panel, proportion=1, flag=wx.EXPAND)
self.SetSizerAndFit(box)
def slider_changed(self, evt):
obj = evt.GetEventObject()
val = obj.GetValue()
self.slider_static.SetLabel('Value: {}'.format(val))
def rangeslider_changed(self, evt):
obj = evt.GetEventObject()
lv, hv = obj.GetValues()
self.rangeslider_static.SetLabel('Low value: {:.0f}, High value: {:.0f}'.format(lv, hv))
def toggle_slider_enable(self, evt):
if self.button_toggle.GetLabel() == 'Disable':
self.slider.Enable(False)
self.rangeslider.Enable(False)
self.button_toggle.SetLabel('Enable')
else:
self.slider.Enable(True)
self.rangeslider.Enable(True)
self.button_toggle.SetLabel('Disable')
def main():
app = wx.App()
TestFrame().Show()
app.MainLoop()
if __name__ == "__main__":
main()
You could have two sliders; one that will push the other so it remains lower, and one will remain higher?
I know it isn't the same thing, sorry, but it is an option.
So when ever self.minSlider is moved, you bind wx.EVT_SCROLL with a function that will do something like:
self.minSlider.Bind(wx.EVT_SCROLL, self.respondSliderChange())
def respondSliderChange(self):
if self.minSlider.GetValue() >= self.maxSlider.GetValue():
self.maxSlider.SetValue(self.minSlider.GetValue()+1)
and vice-versa for the maxSlider.
Besides that, you can look into creating a custom widget here.
Something related has been described here.
In a nutshell the idea is to draw a box, and colour part of it to represent your range. From the left where your user left-click, and from the right where your user right-click.
Instead of a box and colouring, you could draw some markers on a line:
--------[-----------]--
I'm trying to create a program that displays a large grid of numbers (say, filling up a 6 by 4000 grid), where the user can move a cursor around via keyboard or mouse and enter in numbers into the grid. (This is for a guitar tablature program.) I'm new to python GUI programming, and thus far my idea is to have a very large QWidget window (say, 1000x80000 pixels) inside of a QScrollArea inside of the main window. The problem is that every mouse click or cursor movement causes the whole thing to repaint, causing a delay, when I just want to repaint whatever changes I just made to make things faster. In PyQt, is there a way to buffer already-painted graphics and change just the graphics that need changing?
edit: I've posted the code below, which I've run with python3.3 on Mac OS 10.7. The main point is that in the TabWindow init function, the grid size can be set by numXGrid and numYGrid (currently set to 200 and 6), and this grid is filled with random numbers by the generateRandomTablatureData() method. If the grid is filled with numbers, then there's a noticeable lag with every key press, which gets worse with larger grids. (There is also an initial delay due to generating the data, but my question is on the delay after each key press which I assume is due to having to repaint every number.)
There are two files. This is the main one, which I called FAIT.py:
import time
start_time = time.time()
import random
import sys
from PyQt4 import QtGui, QtCore
import Tracks
# generate tracks
tracks = [Tracks.Track(), Tracks.Track(), Tracks.Track()]
fontSize = 16
# margins
xMar = 50
yMar = 50
trackMar = 50 # margin between tracks
class MainWindow(QtGui.QWidget):
def __init__(self):
super(MainWindow, self).__init__()
self.initUI()
end_time = time.time()
print("Initializing time was %g seconds" % (end_time - start_time))
def initUI(self):
# attach QScrollArea to MainWindow
l = QtGui.QVBoxLayout(self)
l.setContentsMargins(0,0,0,0)
l.setSpacing(0)
s=QtGui.QScrollArea()
l.addWidget(s)
# attach TabWindow to QScrollArea so we can paint on it
self.tabWindow=TabWindow(self)
self.tabWindow.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setFocusPolicy(QtCore.Qt.NoFocus)
vbox=QtGui.QVBoxLayout(self.tabWindow)
s.setWidget(self.tabWindow)
self.positionWindow() # set size and position of main window
self.setWindowTitle('MainWindow')
self.show()
def positionWindow(self):
qr = self.frameGeometry()
cp = QtGui.QDesktopWidget().availableGeometry().center()
width = QtGui.QDesktopWidget().availableGeometry().width() - 100
height = QtGui.QDesktopWidget().availableGeometry().height() - 100
self.resize(width, height)
qr = self.frameGeometry()
cp = QtGui.QDesktopWidget().availableGeometry().center()
qr.moveCenter(cp)
self.move(qr.topLeft())
def keyPressEvent(self, e):
print('key pressed in MainWindow')
def mousePressEvent(self, e):
print('mouse click in MainWindow')
class TabWindow(QtGui.QWidget):
def __init__(self, parent=None):
QtGui.QWidget.__init__(self, parent)
# size of tablature grid
numXGrid = 200
numYGrid = 6
# initialize tablature information first
for i in range(0, len(tracks)):
tracks[i].numXGrid = numXGrid
self.arrangeTracks() # figure out offsets for each track
self.trackFocusNum = 0 # to begin with, focus is on track 0
self.windowSizeX = tracks[0].x0 + tracks[0].dx*(tracks[0].numXGrid+2)
self.windowSizeY = tracks[0].y0
for i in range(0, len(tracks)):
self.windowSizeY = self.windowSizeY + tracks[i].dy * tracks[i].numYGrid + trackMar
self.resize(self.windowSizeX,self.windowSizeY) # size of actual tablature area
# generate random tablature data for testing
self.generateRandomTablatureData()
def keyPressEvent(self, e):
print('key pressed in TabWindow')
i = self.trackFocusNum
if e.key() == QtCore.Qt.Key_Up:
tracks[i].moveCursorUp()
if e.key() == QtCore.Qt.Key_Down:
tracks[i].moveCursorDown()
if e.key() == QtCore.Qt.Key_Left:
tracks[i].moveCursorLeft()
if e.key() == QtCore.Qt.Key_Right:
tracks[i].moveCursorRight()
# check for number input
numberKeys = (QtCore.Qt.Key_0,
QtCore.Qt.Key_1,
QtCore.Qt.Key_2,
QtCore.Qt.Key_3,
QtCore.Qt.Key_4,
QtCore.Qt.Key_5,
QtCore.Qt.Key_6,
QtCore.Qt.Key_7,
QtCore.Qt.Key_8,
QtCore.Qt.Key_9)
if e.key() in numberKeys:
num = int(e.key())-48
# add data
tracks[i].data.addToTab(tracks[i].iCursor, tracks[i].jCursor, num)
# convert entered number to pitch and play note (do later)
# spacebar, backspace, or delete remove data
if e.key() in (QtCore.Qt.Key_Space, QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
tracks[i].data.removeFromTab(tracks[i].iCursor, tracks[i].jCursor)
self.update()
def mousePressEvent(self, e):
print('mouse click in TabWindow')
xPos = e.x()
yPos = e.y()
# check tracks one by one
for i in range(0, len(tracks)):
if (tracks[i].isPositionInside(xPos, yPos)):
tracks[i].moveCursorToPosition(xPos, yPos)
self.trackFocusNum = i
break
else:
continue
self.update()
def paintEvent(self, e):
qp = QtGui.QPainter()
qp.begin(self)
qp.setPen(QtCore.Qt.black)
qp.setBrush(QtCore.Qt.white)
qp.drawRect(0, 0, self.windowSizeX, self.windowSizeY)
self.paintTracks(qp)
self.paintTunings(qp)
self.paintCursor(qp)
self.paintNumbers(qp)
qp.end()
def paintTracks(self, qp):
qp.setPen(QtCore.Qt.black)
qp.setBrush(QtCore.Qt.white)
for i in range(0, len(tracks)):
qp.drawPolyline(tracks[i].polyline)
def paintCursor(self, qp):
i = self.trackFocusNum
qp.setPen(QtCore.Qt.black)
qp.setBrush(QtCore.Qt.black)
qp.drawPolygon(tracks[i].getCursorQPolygon())
def paintNumbers(self, qp):
# iterate through tracks, and iterate through numbers on each track
for i in range(0, len(tracks)):
# make sure track has data to draw
if len(tracks[i].data.data) > 0:
for j in range(0, len(tracks[i].data.data)):
# do actual painting here
# first set color to be inverse cursor color if at cursor
if i == self.trackFocusNum and \
tracks[i].iCursor == tracks[i].data.data[j][0] and \
tracks[i].jCursor == tracks[i].data.data[j][1]:
qp.setPen(QtCore.Qt.white)
else:
qp.setPen(QtCore.Qt.black)
font = QtGui.QFont('Helvetica', fontSize)
qp.setFont(font)
text = str(tracks[i].data.data[j][2])
x1 = tracks[i].convertIndexToPositionX(tracks[i].data.data[j][0])
y1 = tracks[i].convertIndexToPositionY(tracks[i].data.data[j][1])
dx = tracks[i].dx
dy = tracks[i].dy
# height and width of number character(s)
metrics = QtGui.QFontMetrics(font)
tx = metrics.width(text)
ty = metrics.height()
# formula for alignment:
# xMar = (dx-tx)/2 plus offset
x11 = x1 + (dx-tx)/2
y11 = y1 + dy/2+ty/2
qp.drawText(x11, y11, text)
def paintTunings(self, qp):
qp.setPen(QtCore.Qt.black)
font = QtGui.QFont('Helvetica', fontSize)
qp.setFont(font)
for i in range(0, len(tracks)):
for j in range(0, tracks[i].numStrings):
text = tracks[i].convertPitchToLetter(tracks[i].stringTuning[j])
# height and width of characters
metrics = QtGui.QFontMetrics(font)
tx = metrics.width(text)
ty = metrics.height()
x11 = tracks[i].x0 - tx - 10
y11 = tracks[i].convertIndexToPositionY(j) + (tracks[i].dy+ty)/2
qp.drawText(x11, y11, text)
def arrangeTracks(self):
tracks[0].x0 = xMar
tracks[0].y0 = yMar
tracks[0].generateGridQPolyline()
for i in range(1, len(tracks)):
tracks[i].x0 = xMar
tracks[i].y0 = tracks[i-1].y0 + tracks[i-1].height + trackMar
tracks[i].generateGridQPolyline()
def generateRandomTablatureData(self):
t1 = time.time()
for i in range(0, len(tracks)):
# worst case scenario: fill every number
for jx in range(0, tracks[i].numXGrid):
for jy in range(0, tracks[i].numYGrid):
val = random.randint(0,9)
tracks[i].data.addToTab(jx, jy, val)
t2 = time.time()
print("Random number generating time was %g seconds" % (t2 - t1))
def main():
app = QtGui.QApplication(sys.argv)
ex = MainWindow()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
This is the other file, Tracks.py, which contains the Track class and supplementary methods:
# contains classes and methods relating to individual tracks
import math
from PyQt4 import QtGui, QtCore
# class for containing information about a track
class Track:
def __init__(self):
self.data = TabulatureData()
# position offset
self.x0 = 0
self.y0 = 0
self.dx = 20 # default rectangle width
self.dy = 40 # default rectangle height
# current cursor index coordinates
self.iCursor = 0
self.jCursor = 0
# default size of grid
self.numXGrid = 4000
self.numYGrid = 6
self.numStrings = self.numYGrid
# calculated maximum width and height in pixels
self.maxWidth = self.dx * self.numXGrid
self.maxHeight = self.dy * self.numYGrid
# generate points of grid and cursor
self.generateGridQPolyline()
# tuning
self.setTuning([40, 45, 50, 55, 59, 64])
# calculate bounds
self.height = self.numYGrid*self.dy
self.width = self.numXGrid*self.dx
def getCursorIndexX(self, xPos):
iPos = int(math.floor( (xPos-self.x0)/self.dx ))
return iPos
def getCursorIndexY(self, yPos):
jPos = int(math.floor( (yPos-self.y0)/self.dy ))
return jPos
def convertIndexToCoordinates(self, iPos, jPos):
return [self.ConvertIndexToPositionX(iPos),
self.ConvertIndexToPositionY(jPos)]
def convertIndexToPositionX(self, iPos):
return self.x0 + iPos*self.dx
def convertIndexToPositionY(self, jPos):
return self.y0 + jPos*self.dy
def getCursorQPolygon(self):
x1 = self.convertIndexToPositionX(self.iCursor)
y1 = self.convertIndexToPositionY(self.jCursor)
x2 = self.convertIndexToPositionX(self.iCursor+1)
y2 = self.convertIndexToPositionY(self.jCursor+1)
return QtGui.QPolygonF([QtCore.QPoint(x1, y1),
QtCore.QPoint(x1, y2),
QtCore.QPoint(x2, y2),
QtCore.QPoint(x2, y1)])
def generateGridQPolyline(self):
self.points = []
self.polyline = QtGui.QPolygonF()
for i in range(0, self.numXGrid):
for j in range(0, self.numYGrid):
x1 = self.convertIndexToPositionX(i)
y1 = self.convertIndexToPositionY(j)
x2 = self.convertIndexToPositionX(i+1)
y2 = self.convertIndexToPositionY(j+1)
self.points.append([(x1, y1), (x1, y2), (x2, y2), (x2, y1)])
self.polyline << QtCore.QPoint(x1,y1) << \
QtCore.QPoint(x1,y2) << \
QtCore.QPoint(x2,y2) << \
QtCore.QPoint(x2,y1) << \
QtCore.QPoint(x1,y1)
# smoothly connect different rows
jLast = self.numYGrid-1
x1 = self.convertIndexToPositionX(i)
y1 = self.convertIndexToPositionY(jLast)
x2 = self.convertIndexToPositionX(i+1)
y2 = self.convertIndexToPositionY(jLast+1)
self.polyline << QtCore.QPoint(x2,y1)
def isPositionInside(self, xPos, yPos):
if (xPos >= self.x0 and xPos <= self.x0 + self.width and
yPos >= self.y0 and yPos <= self.y0 + self.height):
return True
else:
return False
def moveCursorToPosition(self, xPos, yPos):
self.iCursor = self.getCursorIndexX(xPos)
self.jCursor = self.getCursorIndexY(yPos)
self.moveCursorToIndex(self.iCursor, self.jCursor)
def moveCursorToIndex(self, iPos, jPos):
# check if bounds are breached, and if cursor's already there,
# and if not, move cursor
if iPos == self.iCursor and jPos == self.jCursor:
return
if iPos >= 0 and iPos < self.numXGrid:
if jPos >= 0 and jPos < self.numYGrid:
self.iCursor = iPos
self.jCursor = jPos
return
def moveCursorUp(self):
self.moveCursorToIndex(self.iCursor, self.jCursor-1)
def moveCursorDown(self):
self.moveCursorToIndex(self.iCursor, self.jCursor+1)
def moveCursorLeft(self):
self.moveCursorToIndex(self.iCursor-1, self.jCursor)
def moveCursorRight(self):
self.moveCursorToIndex(self.iCursor+1, self.jCursor)
# return pitch in midi integer notation
def convertNumberToPitch(self, jPos, pitchNum):
return pitchNum + self.stringTuning[(self.numStrings-1) - jPos]
def convertPitchToLetter(self, pitchNum):
p = pitchNum % 12
letters = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B')
return letters[p]
def setTuning(self, tuning):
self.stringTuning = tuning
class TabulatureData:
def __init__(self):
self.data = []
def addToTab(self, i, j, value):
# check if data is already there, and remove it first
if self.getValue(i, j) > -1:
self.removeFromTab(i, j)
self.data.append([i, j, value])
def getValue(self, i, j):
possibleTuples = [x for x in self.data if x[0] == i and x[1] == j]
if possibleTuples == []:
return -1
elif len(possibleTuples) > 1:
print('Warning: more than one number at a location!')
return possibleTuples[0][2] # return third number of tuple
def removeFromTab(self, i, j):
# first get value, if it exists
value = self.getValue(i,j)
if value == -1:
return
else:
# if it exists, then remove
self.data.remove([i, j, value])
1000*80000 is really huge.
So,maybe you should try QGLWidget or something like that?
Or according to Qt document, you should set which region you want to repaint.
some slow widgets need to optimize by painting only the requested region: QPaintEvent::region(). This speed optimization does not change the result, as painting is clipped to that region during event processing. QListView and QTableView do this, for example.
I am attempting to modify the DragImage demo from the wxPython examples so that instead of just moving the image around, dragging an image produces a new copy which is draggable, while the original "source" image remains. A decent analogy is that the original images are like a set of widgets to choose from; clicking and dragging on any one of those produces a widget for you to place wherever (and this can be done multiple times) while the source widget remains.
import os
import glob
import wx
import wx.lib.scrolledpanel as scrolled
class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent)
frm_pnl = MainPanel(self)
self.Show()
class DragShape:
def __init__(self, bmp):
self.bmp = bmp
self.pos = (0,0)
self.shown = True
self.text = None
self.fullscreen = False
def HitTest(self, pt):
rect = self.GetRect()
return rect.InsideXY(pt.x, pt.y)
def GetRect(self):
#return wx.Rect(self.pos[0], self.pos[1], self.bmp.GetWidth(), self.bmp.GetHeight())
return wx.Rect(self.pos[0], self.pos[1], self.bmp.GetWidth()/2, self.bmp.GetHeight()/2)
def Draw(self, dc, op = wx.COPY):
if self.bmp.Ok():
memDC = wx.MemoryDC()
memDC.SelectObject(self.bmp)
#dc.Blit(self.pos[0], self.pos[1],
# self.bmp.GetWidth(), self.bmp.GetHeight(),
# memDC, 0, 0, op, True)
dc.Blit(self.pos[0], self.pos[1],
self.bmp.GetWidth()/2, self.bmp.GetHeight()/2,
memDC, 0, 0, op, True)
return True
else:
return False
class MainPanel(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent, -1, size = (900, 700))
self.shapes = []
#panel for mechanisms
mechPnl = MechanismPanel(self)
mechSzr = wx.BoxSizer(wx.HORIZONTAL)
mechSzr.Add(mechPnl, 1)
selfSizer = wx.BoxSizer(wx.VERTICAL)
selfSizer.Add(mechSzr, 0, wx.EXPAND)
selfSizer.Layout()
self.SetSizer(selfSizer)
self.dragImage = None
self.dragShape = None
self.hiliteShape = None
self.SetCursor(wx.StockCursor(wx.CURSOR_ARROW))
#self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
self.Bind(wx.EVT_PAINT, self.OnPaint)
mechPnl.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
mechPnl.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
mechPnl.Bind(wx.EVT_MOTION, self.OnMotion)
mechPnl.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeaveWindow)
# The mouse is moving
def OnMotion(self, evt):
print "On motion!"
# Ignore mouse movement if we're not dragging.
if not self.dragShape or not evt.Dragging() or not evt.LeftIsDown():
return
# if we have a shape, but haven't started dragging yet
if self.dragShape and not self.dragImage:
# only start the drag after having moved a couple pixels
tolerance = 2
pt = evt.GetPosition()
dx = abs(pt.x - self.dragStartPos.x)
dy = abs(pt.y - self.dragStartPos.y)
if dx <= tolerance and dy <= tolerance:
return
# refresh the area of the window where the shape was so it
# will get erased.
self.dragShape.shown = False
self.RefreshRect(self.dragShape.GetRect(), True)
self.Update()
if self.dragShape.text:
self.dragImage = wx.DragString(self.dragShape.text,
wx.StockCursor(wx.CURSOR_HAND))
else:
self.dragImage = wx.DragImage(self.dragShape.bmp,
wx.StockCursor(wx.CURSOR_HAND))
hotspot = self.dragStartPos - self.dragShape.pos
self.dragImage.BeginDrag(hotspot, self, self.dragShape.fullscreen)
self.dragImage.Move(pt)
self.dragImage.Show()
# if we have shape and image then move it, posibly highlighting another shape.
elif self.dragShape and self.dragImage:
onShape = self.FindShape(evt.GetPosition())
unhiliteOld = False
hiliteNew = False
# figure out what to hilite and what to unhilite
if self.hiliteShape:
if onShape is None or self.hiliteShape is not onShape:
unhiliteOld = True
if onShape and onShape is not self.hiliteShape and onShape.shown:
hiliteNew = True
# if needed, hide the drag image so we can update the window
if unhiliteOld or hiliteNew:
self.dragImage.Hide()
if unhiliteOld:
dc = wx.ClientDC(self)
self.hiliteShape.Draw(dc)
self.hiliteShape = None
if hiliteNew:
dc = wx.ClientDC(self)
self.hiliteShape = onShape
self.hiliteShape.Draw(dc, wx.INVERT)
# now move it and show it again if needed
self.dragImage.Move(evt.GetPosition())
if unhiliteOld or hiliteNew:
self.dragImage.Show()
# Left mouse button up.
def OnLeftUp(self, evt):
print "On left up!"
if not self.dragImage or not self.dragShape:
self.dragImage = None
self.dragShape = None
return
# Hide the image, end dragging, and nuke out the drag image.
self.dragImage.Hide()
self.dragImage.EndDrag()
self.dragImage = None
if self.hiliteShape:
self.RefreshRect(self.hiliteShape.GetRect())
self.hiliteShape = None
# reposition and draw the shape
# Note by jmg 11/28/03
# Here's the original:
#
# self.dragShape.pos = self.dragShape.pos + evt.GetPosition() - self.dragStartPos
#
# So if there are any problems associated with this, use that as
# a starting place in your investigation. I've tried to simulate the
# wx.Point __add__ method here -- it won't work for tuples as we
# have now from the various methods
#
# There must be a better way to do this :-)
#
self.dragShape.pos = (
self.dragShape.pos[0] + evt.GetPosition()[0] - self.dragStartPos[0],
self.dragShape.pos[1] + evt.GetPosition()[1] - self.dragStartPos[1]
)
self.dragShape.shown = True
self.RefreshRect(self.dragShape.GetRect())
self.dragShape = None
# Fired whenever a paint event occurs
def OnPaint(self, evt):
print "On paint!"
dc = wx.PaintDC(self)
self.PrepareDC(dc)
self.DrawShapes(dc)
# Left mouse button is down.
def OnLeftDown(self, evt):
print "On left down!"
# Did the mouse go down on one of our shapes?
shape = self.FindShape(evt.GetPosition())
# If a shape was 'hit', then set that as the shape we're going to
# drag around. Get our start position. Dragging has not yet started.
# That will happen once the mouse moves, OR the mouse is released.
if shape:
self.dragShape = shape
self.dragStartPos = evt.GetPosition()
# Go through our list of shapes and draw them in whatever place they are.
def DrawShapes(self, dc):
for shape in self.shapes:
if shape.shown:
shape.Draw(dc)
# This is actually a sophisticated 'hit test', but in this
# case we're also determining which shape, if any, was 'hit'.
def FindShape(self, pt):
for shape in self.shapes:
if shape.HitTest(pt):
return shape
return None
# Clears the background, then redraws it. If the DC is passed, then
# we only do so in the area so designated. Otherwise, it's the whole thing.
def OnEraseBackground(self, evt):
dc = evt.GetDC()
if not dc:
dc = wx.ClientDC(self)
rect = self.GetUpdateRegion().GetBox()
dc.SetClippingRect(rect)
self.TileBackground(dc)
# tile the background bitmap
def TileBackground(self, dc):
sz = self.GetClientSize()
w = self.bg_bmp.GetWidth()
h = self.bg_bmp.GetHeight()
x = 0
while x < sz.width:
y = 0
while y < sz.height:
dc.DrawBitmap(self.bg_bmp, x, y)
y = y + h
x = x + w
# We're not doing anything here, but you might have reason to.
# for example, if you were dragging something, you might elect to
# 'drop it' when the cursor left the window.
def OnLeaveWindow(self, evt):
pass
class MechanismPanel(scrolled.ScrolledPanel):
def __init__(self, parent):
scrolled.ScrolledPanel.__init__(self, parent, -1, size = (400, 140))
self.SetBackgroundColour((211, 211, 211))
mechPnlSzr = wx.BoxSizer(wx.HORIZONTAL)
os.chdir("./figures")
position = 50
for file in glob.glob("icon*.png"):
print file
imgIcon = wx.Image(file, wx.BITMAP_TYPE_PNG).ConvertToBitmap()
staticBitmap = wx.StaticBitmap(self, -1, imgIcon, (position, 50), (50, 50))
shape = DragShape(staticBitmap.GetBitmap())
shape.pos = (position, 50)
position = position + 100
shape.fullscreen = True
parent.shapes.append(shape)
mechPnlSzr.Add(staticBitmap, 0, wx.FIXED, border = 20)
self.SetSizer(mechPnlSzr)
self.SetAutoLayout(1)
self.SetupScrolling()#scroll_y = False)
app = wx.App(False)
frame = MainWindow(None, "Trading Client")
app.MainLoop()