Related
I am trying to modify my code below to take-in mouse events (click, drag, release) such that the control points can be selected and moved, resulting in change in the curve. I am not sure where to begin, any suggestions? The control points are marked as red dots, the curve is in blue.
This would basically let me modify the curve within the gui. Any reference would be appreciated as well.
import sys
import random
import functools
from PyQt5 import QtWidgets, QtGui, QtCore
#functools.lru_cache(maxsize=100)
def factorial(n):
prod = 1
for i in range(1,n+1):
prod *= i
return prod
def randPt(minv, maxv):
return (random.randint(minv, maxv), random.randint(minv, maxv))
def B(i,n,u):
val = factorial(n)/(factorial(i)*factorial(n-i))
return val * (u**i) * ((1-u)**(n-i))
def C(u, pts):
x = 0
y = 0
n = len(pts)-1
for i in range(n+1):
binu = B(i,n,u)
x += binu * pts[i][0]
y += binu * pts[i][1]
return (x, y)
class BezierDrawer(QtWidgets.QWidget):
def __init__(self):
super(BezierDrawer, self).__init__()
self.setGeometry(300, 300, 1500,1000)
self.setWindowTitle('Bezier Curves')
def paintEvent(self, e):
qp = QtGui.QPainter()
qp.begin(self)
qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
self.doDrawing(qp)
qp.end()
def doDrawing(self, qp):
blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
redBrush = QtGui.QBrush(QtCore.Qt.red)
steps = 400
min_t = 0.0
max_t = 1.0
dt = (max_t - min_t)/steps
# controlPts = [randPt(0,1000) for i in range(6)]
# controlPts.append(controlPts[1])
# controlPts.append(controlPts[0])
controlPts = [(500,500), (600,700), (600,550), (700,500),(700,500), (800,400), (1000,200), (1000,500)]
oldPt = controlPts[0]
pn = 1
qp.setPen(redPen)
qp.setBrush(redBrush)
qp.drawEllipse(oldPt[0]-3, oldPt[1]-3, 6,6)
#qp.drawText(oldPt[0]+5, oldPt[1]-3, '{}'.format(pn))
for pt in controlPts[1:]:
pn+=1
qp.setPen(blackPen)
qp.drawLine(oldPt[0],oldPt[1],pt[0],pt[1])
qp.setPen(redPen)
qp.drawEllipse(pt[0]-3, pt[1]-3, 6,6)
#xv=qp.drawText(pt[0]+5, pt[1]-3, '{}'.format(pn))
#xv.setTextWidth(3)
oldPt = pt
qp.setPen(bluePen)
oldPt = controlPts[0]
for i in range(steps+1):
t = dt*i
pt = C(t, controlPts)
qp.drawLine(oldPt[0],oldPt[1], pt[0],pt[1])
oldPt = pt
def main(args):
app = QtWidgets.QApplication(sys.argv)
ex = BezierDrawer()
ex.show()
app.exec_()
if __name__=='__main__':
main(sys.argv[1:])
Premise note: while I normally don't answer such questions with a complete new implementation, the subject of higher-order Bézier curves is quite interesting and not directly available in Qt (and in any general toolkit), so I'm breaking my general rule to provide an expanded answer, as I believe it could be useful to others.
While the accepted answer properly addresses the issue, as stated, using the Graphics View Framework is almost always a better choice, as it provides more modularity, advanced interaction, optimization and features that would be difficult to implement in a standard widget (notably, transformations like scaling and rotation).
Before explaining the implementation, some important notes about the original code:
the factorial() function is slow, and its caching is almost useless, since it will probably never be able to use any cached data due to the amount and diversity of results (there are 400 hardcoded steps, but the cache limit is set to 100); the math module already provides factorial(), which is much faster since it's purely implemented on the C side;
the B() function only executes a computation, so it is almost useless; the same goes for C(): while creating functions may be important for readability and code separation, in this case their usability and usage makes them pointless;
if you want a continuous dashed line for the curve, you cannot use drawLine(), as it will draw distinct segments: since those segments will almost always be very short, no dashed line will be ever shown; use QPainterPath instead;
such extensive and repetitive computation should never be executed in the paintEvent() (which can be called very frequently) and some level of caching should be preferably used; for instance, you can create an empty instance attribute (eg. self.cachePath = None), check if the attribute is empty in the paintEvent() and eventually call a function that creates the path in that case; also consider QPicture;
(unrelated, but still important) calling main with sys.argv[1:] is a bit pointless if you still create the QApplication with the full sys.argv;
In the following implementation I created a main BezierItem as subclass of QGraphicsPathItem which will contain the complete curve and embeds a child QGraphicsPathItem for the line segments and a variable number of ControlPoint objects, which are QGraphicsObject subclasses. The QObject inheritance is to add signal support, which is required to notify about item position changes (it could be technically avoided by using a basic QGraphicsItem and calling the function of the parent, but that wouldn't be very elegant).
Note that, since the curve can potentially call the point computation thousands of times, optimization is of utmost importance: consider that for a "simple" 8th-order curve with a mere 20-step-per-point precision, the result is that the inner computation will be executed about 1200 times. With 20 points and a 40-step precision, the result is more than 16000 cycles.
The BezierItem also contains a list of control points as QPointFs for optimization reasons, and it uses two important functions: updatePath(), which updates the segments that connect each control point, and then calls _rebuildPath() which actually creates the final curve as a QPainterPath. The distinction is important as you may need to rebuild the path when the resolution changes, but the points are still the same.
Note that you used a peculiar way to show control points (a small ellipse with a very thick pen width, which results in a fancy rounded octagon due to the way Qt draw shapes). This can cause some issues in mouse detection, as shaped QGraphicsItems use their internal path and pen to build the shape for collision detection (including mouse events). To avoid these issues, I created a simplified() path based on that shape that will improve performance and ensure proper behavior.
import random
from math import factorial
from PyQt5 import QtWidgets, QtGui, QtCore
class ControlPoint(QtWidgets.QGraphicsObject):
moved = QtCore.pyqtSignal(int, QtCore.QPointF)
removeRequest = QtCore.pyqtSignal(object)
brush = QtGui.QBrush(QtCore.Qt.red)
# create a basic, simplified shape for the class
_base = QtGui.QPainterPath()
_base.addEllipse(-3, -3, 6, 6)
_stroker = QtGui.QPainterPathStroker()
_stroker.setWidth(30)
_stroker.setDashPattern(QtCore.Qt.DashLine)
_shape = _stroker.createStroke(_base).simplified()
# "cache" the boundingRect for optimization
_boundingRect = _shape.boundingRect()
def __init__(self, index, pos, parent):
super().__init__(parent)
self.index = index
self.setPos(pos)
self.setFlags(
self.ItemIsSelectable
| self.ItemIsMovable
| self.ItemSendsGeometryChanges
| self.ItemStacksBehindParent
)
self.setZValue(-1)
self.setToolTip(str(index + 1))
self.font = QtGui.QFont()
self.font.setBold(True)
def setIndex(self, index):
self.index = index
self.setToolTip(str(index + 1))
self.update()
def shape(self):
return self._shape
def boundingRect(self):
return self._boundingRect
def itemChange(self, change, value):
if change == self.ItemPositionHasChanged:
self.moved.emit(self.index, value)
elif change == self.ItemSelectedHasChanged and value:
# stack this item above other siblings when selected
for other in self.parentItem().childItems():
if isinstance(other, self.__class__):
other.stackBefore(self)
return super().itemChange(change, value)
def contextMenuEvent(self, event):
menu = QtWidgets.QMenu()
removeAction = menu.addAction(QtGui.QIcon.fromTheme('edit-delete'), 'Delete point')
if menu.exec(QtGui.QCursor.pos()) == removeAction:
self.removeRequest.emit(self)
def paint(self, qp, option, widget=None):
qp.setBrush(self.brush)
if not self.isSelected():
qp.setPen(QtCore.Qt.NoPen)
qp.drawPath(self._shape)
qp.setPen(QtCore.Qt.white)
qp.setFont(self.font)
r = QtCore.QRectF(self.boundingRect())
r.setSize(r.size() * 2 / 3)
qp.drawText(r, QtCore.Qt.AlignCenter, str(self.index + 1))
class BezierItem(QtWidgets.QGraphicsPathItem):
_precision = .05
_delayUpdatePath = False
_ctrlPrototype = ControlPoint
def __init__(self, points=None):
super().__init__()
self.setPen(QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine))
self.outlineItem = QtWidgets.QGraphicsPathItem(self)
self.outlineItem.setFlag(self.ItemStacksBehindParent)
self.outlineItem.setPen(QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine))
self.controlItems = []
self._points = []
if points is not None:
self.setPoints(points)
def setPoints(self, pointList):
points = []
for p in pointList:
if isinstance(p, (QtCore.QPointF, QtCore.QPoint)):
# always create a copy of each point!
points.append(QtCore.QPointF(p))
else:
points.append(QtCore.QPointF(*p))
if points == self._points:
return
self._points = []
self.prepareGeometryChange()
while self.controlItems:
item = self.controlItems.pop()
item.setParentItem(None)
if self.scene():
self.scene().removeItem(item)
del item
self._delayUpdatePath = True
for i, p in enumerate(points):
self.insertControlPoint(i, p)
self._delayUpdatePath = False
self.updatePath()
def _createControlPoint(self, index, pos):
ctrlItem = self._ctrlPrototype(index, pos, self)
self.controlItems.insert(index, ctrlItem)
ctrlItem.moved.connect(self._controlPointMoved)
ctrlItem.removeRequest.connect(self.removeControlPoint)
def addControlPoint(self, pos):
self.insertControlPoint(-1, pos)
def insertControlPoint(self, index, pos):
if index < 0:
index = len(self._points)
for other in self.controlItems[index:]:
other.index += 1
other.update()
self._points.insert(index, pos)
self._createControlPoint(index, pos)
if not self._delayUpdatePath:
self.updatePath()
def removeControlPoint(self, cp):
if isinstance(cp, int):
index = cp
else:
index = self.controlItems.index(cp)
item = self.controlItems.pop(index)
self.scene().removeItem(item)
item.setParentItem(None)
for other in self.controlItems[index:]:
other.index -= 1
other.update()
del item, self._points[index]
self.updatePath()
def precision(self):
return self._precision
def setPrecision(self, precision):
precision = max(.001, min(.5, precision))
if self._precision != precision:
self._precision = precision
self._rebuildPath()
def stepRatio(self):
return int(1 / self._precision)
def setStepRatio(self, ratio):
'''
Set the *approximate* number of steps per control point. Note that
the step count is adjusted to an integer ratio based on the number
of control points.
'''
self.setPrecision(1 / ratio)
self.update()
def updatePath(self):
outlinePath = QtGui.QPainterPath()
if self.controlItems:
outlinePath.moveTo(self._points[0])
for point in self._points[1:]:
outlinePath.lineTo(point)
self.outlineItem.setPath(outlinePath)
self._rebuildPath()
def _controlPointMoved(self, index, pos):
self._points[index] = pos
self.updatePath()
def _rebuildPath(self):
'''
Actually rebuild the path based on the control points and the selected
curve precision. The default (0.05, ~20 steps per control point) is
usually enough, lower values result in higher resolution but slower
performance, and viceversa.
'''
self.curvePath = QtGui.QPainterPath()
if self._points:
self.curvePath.moveTo(self._points[0])
count = len(self._points)
steps = round(count / self._precision)
precision = 1 / steps
n = count - 1
# we're going to iterate through points *a lot* of times; with the
# small cost of a tuple, we can cache the inner iterator to speed
# things up a bit, instead of creating it in each for loop cycle
pointIterator = tuple(enumerate(self._points))
for s in range(steps + 1):
u = precision * s
x = y = 0
for i, point in pointIterator:
binu = (factorial(n) / (factorial(i) * factorial(n - i))
* (u ** i) * ((1 - u) ** (n - i)))
x += binu * point.x()
y += binu * point.y()
self.curvePath.lineTo(x, y)
self.setPath(self.curvePath)
class BezierExample(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.bezierScene = QtWidgets.QGraphicsScene()
self.bezierView = QtWidgets.QGraphicsView(self.bezierScene)
self.bezierView.setRenderHints(QtGui.QPainter.Antialiasing)
self.bezierItem = BezierItem([
(500, 500), (600, 700), (600, 550), (700, 500),
(700, 500), (800, 400), (1000, 200), (1000, 500)
])
self.bezierScene.addItem(self.bezierItem)
mainLayout = QtWidgets.QVBoxLayout(self)
topLayout = QtWidgets.QHBoxLayout()
mainLayout.addLayout(topLayout)
topLayout.addWidget(QtWidgets.QLabel('Resolution:'))
resSpin = QtWidgets.QSpinBox(minimum=1, maximum=100)
resSpin.setValue(self.bezierItem.stepRatio())
topLayout.addWidget(resSpin)
topLayout.addStretch()
addButton = QtWidgets.QPushButton('Add point')
topLayout.addWidget(addButton)
mainLayout.addWidget(self.bezierView)
self.bezierView.installEventFilter(self)
resSpin.valueChanged.connect(self.bezierItem.setStepRatio)
addButton.clicked.connect(self.addPoint)
def addPoint(self, point=None):
if not isinstance(point, (QtCore.QPoint, QtCore.QPointF)):
point = QtCore.QPointF(
random.randrange(int(self.bezierScene.sceneRect().width())),
random.randrange(int(self.bezierScene.sceneRect().height())))
self.bezierItem.addControlPoint(point)
def eventFilter(self, obj, event):
if event.type() == event.MouseButtonDblClick:
pos = self.bezierView.mapToScene(event.pos())
self.addPoint(pos)
return True
return super().eventFilter(obj, event)
def sizeHint(self):
return QtWidgets.QApplication.primaryScreen().size() * 2 / 3
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
ex = BezierExample()
ex.show()
app.exec_()
I will probably change the above code in the future, mostly to provide a valid backend for Nth-order curves without control point interaction and eventually a further subclass to add that support.
But, right now, the requested support is complete, and I strongly urge you to carefully take your time in studying every part of that code. The Graphics View framework is as powerful as it's complex to understand, and it may take weeks (at least!) to really get it.
As always, the basic rule remains: study the documentation. And, possibly, the source code.
As it was suggested, for more "serious" application, You should go for GraphicsView.
Your app was however almost done with simple drawing. It's not a big deal to to modify it to work as You want.
I made few changes to Your code. You have to make list of control points as a attribute of Your BezierDrawer. Then You can use events: mousePressEvent, mouseMoveEvent and mouseReleaseEvent to interact with control points.
First we need to find out, if any point was clicked in mousePressEvent and then just update it's position while dragging. Every change of point position must end with update method, to repaint widget.
Here is modified code:
import functools
import random
import sys
from PyQt5 import QtWidgets, QtGui, QtCore
#functools.lru_cache(maxsize=100)
def factorial(n):
prod = 1
for i in range(1, n + 1):
prod *= i
return prod
def randPt(minv, maxv):
return (random.randint(minv, maxv), random.randint(minv, maxv))
def B(i, n, u):
val = factorial(n) / (factorial(i) * factorial(n - i))
return val * (u ** i) * ((1 - u) ** (n - i))
def C(u, pts):
x = 0
y = 0
n = len(pts) - 1
for i in range(n + 1):
binu = B(i, n, u)
x += binu * pts[i][0]
y += binu * pts[i][1]
return (x, y)
class BezierDrawer(QtWidgets.QWidget):
def __init__(self):
super(BezierDrawer, self).__init__()
# Dragged point index
self.dragged_point = None
# List of control points
self.controlPts = [(500, 500), (600, 700), (600, 550), (700, 500), (800, 400), (1000, 200),
(1000, 500)]
self.setGeometry(300, 300, 1500, 1000)
self.setWindowTitle('Bezier Curves')
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
"""Get through all control points and find out if mouse clicked on it"""
for control_point, (x, y) in enumerate(self.controlPts):
if a0.x() - 15 <= x <= a0.x() + 15 and a0.y() - 15 <= y <= a0.y() + 15:
self.dragged_point = control_point
return
def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
"""If any point is dragged, change its position and repaint scene"""
if self.dragged_point is not None:
self.controlPts[self.dragged_point] = (a0.x(), a0.y())
self.update()
def mouseReleaseEvent(self, a0: QtGui.QMouseEvent) -> None:
"""Release dragging point and repaint scene again"""
self.dragged_point = None
self.update()
def paintEvent(self, e):
qp = QtGui.QPainter()
qp.begin(self)
qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
self.doDrawing(qp)
qp.end()
def doDrawing(self, qp):
blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
redBrush = QtGui.QBrush(QtCore.Qt.red)
steps = 400
min_t = 0.0
max_t = 1.0
dt = (max_t - min_t) / steps
oldPt = self.controlPts[0]
pn = 1
qp.setPen(redPen)
qp.setBrush(redBrush)
qp.drawEllipse(oldPt[0] - 3, oldPt[1] - 3, 6, 6)
for pt in self.controlPts[1:]:
pn += 1
qp.setPen(blackPen)
qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])
qp.setPen(redPen)
qp.drawEllipse(pt[0] - 3, pt[1] - 3, 6, 6)
oldPt = pt
qp.setPen(bluePen)
oldPt = self.controlPts[0]
for i in range(steps + 1):
t = dt * i
pt = C(t, self.controlPts)
qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])
oldPt = pt
def main(args):
app = QtWidgets.QApplication(sys.argv)
ex = BezierDrawer()
ex.show()
app.exec_()
if __name__ == '__main__':
main(sys.argv[1:])
An interesting discussion was raised here about preventing collisions of circles, made of QGraphicsEllipseItems, in a QGraphicsScene. The question narrowed the scope to 2 colliding items but the larger goal still remained, what about for any number of collisions?
This is the desired behavior:
When one item is dragged over other items they should not overlap, instead it should move around those items as close as possible to the mouse.
It should not “teleport” if it gets blocked in by other items.
It should be a smooth and predictable movement.
As this becomes increasingly complex to find the best “safe” position for the circle while it’s moving I wanted to present another way to implement this using a physics simulator.
Given the behavior described above it’s a good candidate for 2D rigid body physics, maybe it can be done without but it would be difficult to get it perfect. I am using pymunk in this example because I’m familiar with it but the same concepts will work with other libraries.
The scene has a kinematic body to represent the mouse and the circles are represented by static bodies initially. While a circle is selected it switches to a dynamic body and is constrained to the mouse by a damped spring. Its position is updated as the space is updated by a given time step on each timeout interval.
The item is not actually moved in the same way as the ItemIsMovable flag is not enabled, which means it no longer moves instantly with the mouse. It’s very close but there’s a small delay, although you may prefer this to better see how it reacts to collisions. (Even so, you can fine-tune the parameters to have it move faster/closer to the mouse than I did**).
On the other hand, the collisions are handled perfectly and will already support other kinds of shapes.
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import pymunk
class Circle(QGraphicsEllipseItem):
def __init__(self, r, **kwargs):
super().__init__(-r, -r, r * 2, r * 2, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.static = pymunk.Body(body_type=pymunk.Body.STATIC)
self.circle = pymunk.Circle(self.static, r)
self.circle.friction = 0
mass = 10
self.dynamic = pymunk.Body(mass, pymunk.moment_for_circle(mass, 0, r))
self.updatePos = lambda: self.setPos(*self.dynamic.position, dset=False)
def setPos(self, *pos, dset=True):
super().setPos(*pos)
if len(pos) == 1:
pos = pos[0].x(), pos[0].y()
self.static.position = pos
if dset:
self.dynamic.position = pos
def itemChange(self, change, value):
if change == QGraphicsItem.ItemSelectedChange:
space = self.circle.space
space.remove(self.circle.body, self.circle)
self.circle.body = self.dynamic if value else self.static
space.add(self.circle.body, self.circle)
return super().itemChange(change, value)
def paint(self, painter, option, widget):
option.state &= ~QStyle.State_Selected
super().paint(painter, option, widget)
class Scene(QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.space = pymunk.Space()
self.space.damping = 0.02
self.body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
self.space.add(self.body)
self.timer = QTimer(self, timerType=Qt.PreciseTimer, timeout=self.step)
self.selectionChanged.connect(self.setConstraint)
def setConstraint(self):
selected = self.selectedItems()
if selected:
shape = selected[0].circle
if not shape.body.constraints:
self.space.remove(*self.space.constraints)
spring = pymunk.DampedSpring(
self.body, shape.body, (0, 0), (0, 0),
rest_length=0, stiffness=100, damping=10)
spring.collide_bodies = False
self.space.add(spring)
def step(self):
for i in range(10):
self.space.step(1 / 30)
self.selectedItems()[0].updatePos()
def mousePressEvent(self, event):
super().mousePressEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
self.timer.start(1000 / 30)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.timer.stop()
def addCircle(self, x, y, radius):
item = Circle(radius)
item.setPos(x, y)
self.addItem(item)
self.space.add(item.circle.body, item.circle)
return item
if __name__ == '__main__':
app = QApplication(sys.argv)
scene = Scene(0, 0, 1000, 800)
for i in range(7, 13):
item = scene.addCircle(150 * (i - 6), 400, i * 5)
item.setBrush(Qt.GlobalColor(i))
view = QGraphicsView(scene, renderHints=QPainter.Antialiasing)
view.show()
sys.exit(app.exec_())
**Can adjust the following:
Spring stiffness and damping
Body mass and moment of inertia
Space damping
Space.step time step / how many calls per QTimer timeout
QTimer interval
I have a media player developed in Python and I have created a button that extracts the actual value of the slider's tick, the problem is it would be great if I can see it on the slider just to know the position of the mark I've already extracted. The idea is every time I push the button a mark should appear on the slider in that value's position. Anyone knows how to do it?
While QSlider offers the possibility to draw tick marks, they are only possible for constant intervals.
A possible solution is to create a custom widget that "embeds" the slider in a layout with an appropriate margin (on top, for example) that will be then painted over with the requested tickmarks. While this solution is effective, it might not be the most consistent, as it results in making the whole slider occupy a lot of space for the ticks.
class TickSlider(QtWidgets.QWidget):
valueChanged = QtCore.pyqtSignal(int)
def __init__(self):
super().__init__()
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 12, 0, 0)
layout.setSpacing(0)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal, maximum=101)
layout.addWidget(self.slider)
# link slider functions; be aware that this is not usually advised
# and you should better write specific functions that call the actual
# slider methods
self.value = self.slider.value
self.setValue = self.slider.setValue
self.minimum = self.slider.minimum
self.setMinimum = self.slider.setMinimum
self.maximum = self.slider.maximum
self.setMaximum = self.slider.setMaximum
self.ticks = set()
self.slider.valueChanged.connect(self.valueChanged)
def addTick(self, value=None):
if isinstance(value, bool) or value is None:
value = self.slider.value()
if not value in self.ticks and self.minimum() <= value <= self.maximum():
self.ticks.add(value)
self.update()
def removeTick(self, value=None):
if isinstance(value, bool) or value is None:
value = self.slider.value()
if value in self.ticks:
self.ticks.discard(value)
self.update()
def paintEvent(self, event):
if not self.ticks:
return
sliderMin = self.slider.minimum()
sliderMax = self.slider.maximum()
style = self.style()
opt = QtWidgets.QStyleOptionSlider()
self.slider.initStyleOption(opt)
sliderLength = style.pixelMetric(
style.PM_SliderLength, opt, self.slider)
span = style.pixelMetric(
style.PM_SliderSpaceAvailable, opt, self.slider)
qp = QtGui.QPainter(self)
qp.translate(opt.rect.x() + sliderLength / 2, 0)
y = self.slider.y() - 2
for value in sorted(self.ticks):
x = style.sliderPositionFromValue(
sliderMin, sliderMax, value, span)
qp.drawLine(x, 0, x, y)
Another possibility implies some "hacking" around the current style.
The major problem comes from the fact that in some systems and styles, Qt uses pixmaps to draw objects, and this "overpaints" any previous attempt to draw something "under" the actual slider.
The trick is to paint components of the slider individually, so that the ticks can be painted over the background (including the groove in which the handle moves) and before the handle is actually painted.
This approach offers the major benefit of directly using a QSlider instead of a container widget.
class TickOverride(QtWidgets.QSlider):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ticks = set()
self.setTickPosition(self.TicksAbove)
def addTick(self, value=None):
if isinstance(value, bool) or value is None:
value = self.value()
if not value in self.ticks and self.minimum() <= value <= self.maximum():
self.ticks.add(value)
self.update()
def removeTick(self, value=None):
if isinstance(value, bool) or value is None:
value = self.value()
if value in self.ticks:
self.ticks.discard(value)
self.update()
def paintEvent(self, event):
qp = QtWidgets.QStylePainter(self)
opt = QtWidgets.QStyleOptionSlider()
style = self.style()
self.initStyleOption(opt)
# draw the groove only
opt.subControls = style.SC_SliderGroove
qp.drawComplexControl(style.CC_Slider, opt)
sliderMin = self.minimum()
sliderMax = self.maximum()
sliderLength = style.pixelMetric(style.PM_SliderLength, opt, self)
span = style.pixelMetric(style.PM_SliderSpaceAvailable, opt, self)
# if the tick option is set and ticks actually exist, draw them
if self.ticks and self.tickPosition():
qp.save()
qp.translate(opt.rect.x() + sliderLength / 2, 0)
grooveRect = style.subControlRect(
style.CC_Slider, opt, style.SC_SliderGroove)
grooveTop = grooveRect.top() - 1
grooveBottom = grooveRect.bottom() + 1
ticks = self.tickPosition()
bottom = self.height()
for value in sorted(self.ticks):
x = style.sliderPositionFromValue(
sliderMin, sliderMax, value, span)
if ticks & self.TicksAbove:
qp.drawLine(x, 0, x, grooveTop)
if ticks & self.TicksBelow:
qp.drawLine(x, grooveBottom, x, bottom)
qp.restore()
opt.subControls = style.SC_SliderHandle
opt.activeSubControls = style.SC_SliderHandle
if self.isSliderDown():
opt.state |= style.State_Sunken
qp.drawComplexControl(style.CC_Slider, opt)
Note: this answer obviously covers only horizontal sliders for simplicity, and considering the purpose required by the OP.
Can somebody please provide an explanation, or better yet a short example, of how to use the RubberBandDrag enum value in QGraphicsView? PyQt5 would be great, but I can translate from the C++ version if that is preferred for whomever can provide a helpful exmaple.
NoDrag and ScrollHandDrag are relatively easy to understand (NoDrag makes the mouse a pointer and you can capture clicks at certain locations, ScrollHandDrag makes the mouse a hand and you can implement click and drag to scroll around), but I'm unclear on what RubberBandDrag can be used for.
Before somebody says "go read the docs", here is the information provided
https://doc.qt.io/qt-5/qgraphicsview.html
enum QGraphicsView::DragMode
QGraphicsView::RubberBandDrag
A rubber band will appear. Dragging the mouse will set the rubber band
geometry, and all items covered by the rubber band are selected. This
mode is disabled for non-interactive views.
This is clear but I'm not sure how I could actually use RubberBandDrag. Is there a way to use this to drag points of a QPolygon around after initial placement? That would be really useful.
The QGraphicsView::RubberBandDrag flag only serves to activate the internal QRubberBand:
And the QRubberBand in general only aims to visualize a selected area and in the case of QGraphicsView select the items below that area if they are selectable(enable QGraphicsItem::ItemIsSelectable flag).
According to your last question: Is there a way to use this to drag points of a QPolygon around after initial placement? That would be really useful, it seems to me that you have an XY problem since it seems that the use of drag in the terminology makes you think that it serves to drag elements, because no, that drag refers to the way of creating the rubber band.
In a few moments I will show how to implement the drag of the vertices to modify the QPolygon.
The following shows how to modify the position of the vertices by dragging the mouse:
import math
from PyQt5 import QtCore, QtGui, QtWidgets
class GripItem(QtWidgets.QGraphicsPathItem):
circle = QtGui.QPainterPath()
circle.addEllipse(QtCore.QRectF(-10, -10, 20, 20))
square = QtGui.QPainterPath()
square.addRect(QtCore.QRectF(-15, -15, 30, 30))
def __init__(self, annotation_item, index):
super(GripItem, self).__init__()
self.m_annotation_item = annotation_item
self.m_index = index
self.setPath(GripItem.circle)
self.setBrush(QtGui.QColor("green"))
self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
self.setAcceptHoverEvents(True)
self.setZValue(11)
self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
def hoverEnterEvent(self, event):
self.setPath(GripItem.square)
self.setBrush(QtGui.QColor("red"))
super(GripItem, self).hoverEnterEvent(event)
def hoverLeaveEvent(self, event):
self.setPath(GripItem.circle)
self.setBrush(QtGui.QColor("green"))
super(GripItem, self).hoverLeaveEvent(event)
def mouseReleaseEvent(self, event):
self.setSelected(False)
super(GripItem, self).mouseReleaseEvent(event)
def itemChange(self, change, value):
if change == QtWidgets.QGraphicsItem.ItemPositionChange and self.isEnabled():
self.m_annotation_item.movePoint(self.m_index, value)
return super(GripItem, self).itemChange(change, value)
class PolygonAnnotation(QtWidgets.QGraphicsPolygonItem):
def __init__(self, parent=None):
super(PolygonAnnotation, self).__init__(parent)
self.m_points = []
self.setZValue(10)
self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
self.setAcceptHoverEvents(True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
self.m_items = []
def addPoint(self, p):
self.m_points.append(p)
self.setPolygon(QtGui.QPolygonF(self.m_points))
item = GripItem(self, len(self.m_points) - 1)
self.scene().addItem(item)
self.m_items.append(item)
item.setPos(p)
def movePoint(self, i, p):
if 0 <= i < len(self.m_points):
self.m_points[i] = self.mapFromScene(p)
self.setPolygon(QtGui.QPolygonF(self.m_points))
def move_item(self, index, pos):
if 0 <= index < len(self.m_items):
item = self.m_items[index]
item.setEnabled(False)
item.setPos(pos)
item.setEnabled(True)
def itemChange(self, change, value):
if change == QtWidgets.QGraphicsItem.ItemPositionHasChanged:
for i, point in enumerate(self.m_points):
self.move_item(i, self.mapToScene(point))
return super(PolygonAnnotation, self).itemChange(change, value)
def hoverEnterEvent(self, event):
self.setBrush(QtGui.QColor(255, 0, 0, 100))
super(PolygonAnnotation, self).hoverEnterEvent(event)
def hoverLeaveEvent(self, event):
self.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush))
super(PolygonAnnotation, self).hoverLeaveEvent(event)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
scene = QtWidgets.QGraphicsScene()
w = QtWidgets.QGraphicsView(scene)
polygon_item = PolygonAnnotation()
scene.addItem(polygon_item)
r = 100
sides = 10
for i in range(sides):
angle = 2 * math.pi * i / sides
x = r * math.cos(angle)
y = r * math.sin(angle)
p = QtCore.QPointF(x, y) + QtCore.QPointF(200, 200)
polygon_item.addPoint(p)
w.resize(640, 480)
w.show()
sys.exit(app.exec_())
I'm working on a PyQt5 GUI, so far, I've just had experience with python scripts and did not delve into creating user interfaces.
The GUI will have to be used on different screens (maybe also some old 4:3 ratio screens) and will need to look nice in different sizes.
Now, my approach to make my life easier was to enforce a fixed aspect ratio of the window and resize the different elements according to window size.
from PyQt5 import QtCore, QtGui, QtWidgets
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent= None):
super().__init__(parent)
self.form_widget = FormWidget(self)
self.setCentralWidget(self.form_widget)
self.resize(200, 400)
self.sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
self.sizePolicy.setHeightForWidth(True)
self.setSizePolicy(self.sizePolicy)
def heightForWidth(self, width):
return width * 2
class FormWidget(QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
def resizeEvent(self, event):
f = self.font()
temp = event.size().height()
f.setPixelSize(temp / 16)
self.setFont(f)
return super().resizeEvent(event)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
Resizing the elements according to window size works fine, but window aspect ratio is not kept at all.
I copied this approach with heightForWidth from old PyQt4 threads. Doesn't this approach work anymore in PyQt5? Am I missing something?
If I understood your question, you should try using a layout inside the main window.
I did this:
from PyQt5 import QtCore, QtGui, QtWidgets
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent= None):
super().__init__(parent)
self.central_widget = QtWidgets.QWidget()
self.central_layout = QtWidgets.QVBoxLayout()
self.setCentralWidget(self.central_widget)
self.central_widget.setLayout(self.central_layout)
# Lets create some widgets inside
self.label = QtWidgets.QLabel()
self.list_view = QtWidgets.QListView()
self.push_button = QtWidgets.QPushButton()
self.label.setText('Hi, this is a label. And the next one is a List View :')
self.push_button.setText('Push Button Here')
# Lets add the widgets
self.central_layout.addWidget(self.label)
self.central_layout.addWidget(self.list_view)
self.central_layout.addWidget(self.push_button)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
If you resize the window, the widgets inside it get resized.
First, answered by Marc and codeling in this question, heightForWidth is only supported for QGraphicsLayout's subclasses.
Second, how to make a fixed aspect ratio window (or top-level widget) in qt (or pyqt) is a question that have been asked for years. However, as far as I know, there is no standard way of doing so, and it is something surprisingly hard to achieve. In short, my way of doing this is use Qt.FramelessWindowHint to create a frameless window without system move and resize function, and implement custom move and resize.
Explain important mechanism:
move:
In mousePressEvent, keep the place where we last clicked on the widget(the draggable area).
In mouseMoveEvent, calculate the distance between the last clicked point and the current mouse location. Move the window according to this distance.
resize:
Find the increase or decrease step size of width and height by dividing the minimum width and height of the window by their highest common factor.
Use the step size to increase or decrease the window size to keep the aspect ratio.
A screenshot to show that it can resize according to the aspect ratio.
The following code should works with both PyQt5 and Pyside2.
from PyQt5.QtCore import Qt, QRect, QPoint, QEvent
from PyQt5.QtWidgets import (QLabel, QMainWindow, QApplication, QSizePolicy,
QVBoxLayout, QWidget, QHBoxLayout, QPushButton)
from enum import Enum
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
self.setWindowFlags(Qt.FramelessWindowHint)
self.createCostumTitleBar()
self.setContentsMargins(0, 0, 0, 0)
self.central = QWidget()
self.central.setStyleSheet("background-color: #f8ecdf")
self.centralLayout = QVBoxLayout()
self.central.setLayout(self.centralLayout)
self.centralLayout.addWidget(
self.costumsystemmenu, alignment=Qt.AlignTop)
self.centralLayout.setContentsMargins(0, 0, 0, 0)
self.setCentralWidget(self.central)
# Set the minimum size to avoid window being resized too small.
self.setMinimumSize(300, 400)
self.minheight = self.minimumHeight()
self.minwidth = self.minimumWidth()
self.resize(300, 400)
# make sure your minium size have the same aspect ratio as the step.
self.stepY = 4
self.stepX = 3
# install the event filter on this window.
self.installEventFilter(self)
self.grabarea.installEventFilter(self)
self.cursorpos = CursorPos.DEFAULT
self.iswindowpress = False
def createCostumTitleBar(self):
self.costumsystemmenu = QWidget()
self.costumsystemmenu.setStyleSheet("background-color: #ccc")
self.costumsystemmenu.setContentsMargins(0, 0, 0, 0)
self.costumsystemmenu.setMinimumHeight(30)
self.grabarea = QLabel("")
self.grabarea.setStyleSheet("background-color: #ccc")
self.grabarea.setSizePolicy(
QSizePolicy.Expanding, QSizePolicy.Preferred)
titlebarlayout = QHBoxLayout()
titlebarlayout.setContentsMargins(11, 11, 11, 11)
titlebarlayout.setSpacing(0)
self.closeButton = QPushButton("X")
self.closeButton.setSizePolicy(
QSizePolicy.Minimum, QSizePolicy.Preferred)
self.closeButton.clicked.connect(self.close)
self.costumsystemmenu.setLayout(titlebarlayout)
titlebarlayout.addWidget(self.grabarea)
titlebarlayout.addWidget(self.closeButton, alignment=Qt.AlignRight)
self.istitlebarpress = False
def eventFilter(self, object, event):
# The eventFilter() function must return true if the event
# should be filtered, (i.e. stopped); otherwise it must return false.
# https://doc.qt.io/qt-5/qobject.html#eventFilter
# check if the object is the mainwindow.
if object == self:
if event.type() == QEvent.HoverMove:
if not self.iswindowpress:
self.setCursorShape(event)
return True
elif event.type() == QEvent.MouseButtonPress:
self.iswindowpress = True
# Get the position of the cursor and map to the global coordinate of the widget.
self.globalpos = self.mapToGlobal(event.pos())
self.origingeometry = self.geometry()
return True
elif event.type() == QEvent.MouseButtonRelease:
self.iswindowpress = False
return True
elif event.type() == QEvent.MouseMove:
if self.cursorpos != CursorPos.DEFAULT and self.iswindowpress:
self.resizing(self.globalpos, event,
self.origingeometry, self.cursorpos)
return True
else:
return False
elif object == self.grabarea:
if event.type() == QEvent.MouseButtonPress:
if event.button() == Qt.LeftButton and self.iswindowpress == False:
self.oldpos = event.globalPos()
self.oldwindowpos = self.pos()
self.istitlebarpress = True
return True
elif event.type() == QEvent.MouseButtonRelease:
self.istitlebarpress = False
return True
elif event.type() == QEvent.MouseMove:
if (self.istitlebarpress):
distance = event.globalPos()-self.oldpos
newwindowpos = self.oldwindowpos + distance
self.move(newwindowpos)
return True
else:
return False
else:
return False
# Change the cursor shape when the cursor is over different part of the window.
def setCursorShape(self, event, handlersize=11):
rect = self.rect()
topLeft = rect.topLeft()
topRight = rect.topRight()
bottomLeft = rect.bottomLeft()
bottomRight = rect.bottomRight()
# get the position of the cursor
pos = event.pos()
# make the resize handle include some space outside the window,
# can avoid user move too fast and loss the handle.
# top handle
if pos in QRect(QPoint(topLeft.x()+handlersize, topLeft.y()-2*handlersize),
QPoint(topRight.x()-handlersize, topRight.y()+handlersize)):
self.setCursor(Qt.SizeVerCursor)
self.cursorpos = CursorPos.TOP
# bottom handle
elif pos in QRect(QPoint(bottomLeft.x()+handlersize, bottomLeft.y()-handlersize),
QPoint(bottomRight.x()-handlersize, bottomRight.y()+2*handlersize)):
self.setCursor(Qt.SizeVerCursor)
self.cursorpos = CursorPos.BOTTOM
# right handle
elif pos in QRect(QPoint(topRight.x()-handlersize, topRight.y()+handlersize),
QPoint(bottomRight.x()+2*handlersize, bottomRight.y()-handlersize)):
self.setCursor(Qt.SizeHorCursor)
self.cursorpos = CursorPos.RIGHT
# left handle
elif pos in QRect(QPoint(topLeft.x()-2*handlersize, topLeft.y()+handlersize),
QPoint(bottomLeft.x()+handlersize, bottomLeft.y()-handlersize)):
self.setCursor(Qt.SizeHorCursor)
self.cursorpos = CursorPos.LEFT
# topRight handle
elif pos in QRect(QPoint(topRight.x()-handlersize, topRight.y()-2*handlersize),
QPoint(topRight.x()+2*handlersize, topRight.y()+handlersize)):
self.setCursor(Qt.SizeBDiagCursor)
self.cursorpos = CursorPos.TOPRIGHT
# topLeft handle
elif pos in QRect(QPoint(topLeft.x()-2*handlersize, topLeft.y()-2*handlersize),
QPoint(topLeft.x()+handlersize, topLeft.y()+handlersize)):
self.setCursor(Qt.SizeFDiagCursor)
self.cursorpos = CursorPos.TOPLEFT
# bottomRight handle
elif pos in QRect(QPoint(bottomRight.x()-handlersize, bottomRight.y()-handlersize),
QPoint(bottomRight.x()+2*handlersize, bottomRight.y()+2*handlersize)):
self.setCursor(Qt.SizeFDiagCursor)
self.cursorpos = CursorPos.BOTTOMRIGHT
# bottomLeft handle
elif pos in QRect(QPoint(bottomLeft.x()-2*handlersize, bottomLeft.y()-handlersize),
QPoint(bottomLeft.x()+handlersize, bottomLeft.y()+2*handlersize)):
self.setCursor(Qt.SizeBDiagCursor)
self.cursorpos = CursorPos.BOTTOMLEFT
# Default is the arrow cursor.
else:
self.setCursor(Qt.ArrowCursor)
self.cursorpos = CursorPos.DEFAULT
def resizing(self, originpos, event, geo, cursorpos):
newpos = self.mapToGlobal(event.pos())
# find the distance between new and old cursor position.
dist = newpos - originpos
# calculate the steps to grow or srink.
if cursorpos in [CursorPos.TOP, CursorPos.BOTTOM,
CursorPos.TOPRIGHT,
CursorPos.BOTTOMLEFT, CursorPos.BOTTOMRIGHT]:
steps = dist.y()//self.stepY
elif cursorpos in [CursorPos.LEFT, CursorPos.TOPLEFT, CursorPos.RIGHT]:
steps = dist.x()//self.stepX
# if the distance moved is too stort, grow or srink by 1 step.
if steps == 0:
steps = -1 if dist.y() < 0 or dist.x() < 0 else 1
oldwidth = geo.width()
oldheight = geo.height()
oldX = geo.x()
oldY = geo.y()
if cursorpos in [CursorPos.TOP, CursorPos.TOPRIGHT]:
width = oldwidth - steps * self.stepX
height = oldheight - steps * self.stepY
newX = oldX
newY = oldY + (steps * self.stepY)
# check if the new size is within the size limit.
if height >= self.minheight and width >= self.minwidth:
self.setGeometry(newX, newY, width, height)
elif cursorpos in [CursorPos.BOTTOM, CursorPos.RIGHT, CursorPos.BOTTOMRIGHT]:
width = oldwidth + steps * self.stepX
height = oldheight + steps * self.stepY
self.resize(width, height)
elif cursorpos in [CursorPos.LEFT, CursorPos.BOTTOMLEFT]:
width = oldwidth - steps * self.stepX
height = oldheight - steps * self.stepY
newX = oldX + steps * self.stepX
newY = oldY
# check if the new size is within the size limit.
if height >= self.minheight and width >= self.minwidth:
self.setGeometry(newX, newY, width, height)
elif cursorpos == CursorPos.TOPLEFT:
width = oldwidth - steps * self.stepX
height = oldheight - steps * self.stepY
newX = oldX + steps * self.stepX
newY = oldY + steps * self.stepY
# check if the new size is within the size limit.
if height >= self.minheight and width >= self.minwidth:
self.setGeometry(newX, newY, width, height)
else:
pass
# cursor position
class CursorPos(Enum):
TOP = 1
BOTTOM = 2
RIGHT = 3
LEFT = 4
TOPRIGHT = 5
TOPLEFT = 6
BOTTOMRIGHT = 7
BOTTOMLEFT = 8
DEFAULT = 9
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
Finally, I'd like to give special thanks to the authors and editors of this question, GLHF, DRPK, Elad Joseph, and SimoN SavioR. Without their contribution to the community, it wouldn't be possible to come up with this answer.