I am trying to rebuild a screen record PyQt App, and the ScreenToGIF is a very good demo for me, it creates an interface which only has the border and record contents in the "Central Widgets", like this:
with key functions of:
The border exists and can be drag and resize by mouse
the inner content is transparent
the mouse click can penetrate through the app, and interact with other app beneath it.
However, it is implemented in C# (link:https://github.com/NickeManarin/ScreenToGif), I am wondering whether it possible to make a similar PyQt App without learning to be expertise about C#?
Changing the background image of QMainWidgets to the desktop area been overlayed doesn't make sense, because mouse operation on desktop (such as double click to open files) should be recorded. Mouse event can penetrate the app (like Qt.WindowTransparentForInput applied for inner contents?)
What you want to achieve requires setting a mask, allowing you to have a widget that has a specific "shape" that doesn't have to be a rectangle.
The main difficulty is to understand how window geometries work, which can be tricky.
You have to ensure that the window "frame" (which includes its margins and titlebar - if any) has been computed, then find out the inner rectangle and create a mask accordingly. Note that on Linux this happens "some time" after show() has been called; I think you're on Windows, but I've implemented it in a way that should work fine for both Linux, MacOS and Windows. There's a comment about that, if you're sure that your program will run on Windows only.
Finally, I've only been able to run this on Linux, Wine and a virtualized WinXP environment. It should work fine on any system, but, from my experience, there's a specific "cosmetic" bug: the title bar is not painted according to the current Windows theme. I think that this is due to the fact that whenever a mask is applied, the underlying windows system doesn't draw its "styled" window frame as it usually would. If this happens in newer systems also, there could be a workaround, but it's not easy, and I cannot guarantee that it would solve this issue.
NB: remember that this approach will never allow you to draw anything inside the "grab rectangle" (no shade, nor semi-transparent color mask); the reason for this is that you obviously need to achieve mouse interaction with what is "beneath" the widget, and painting over it would require altering the overlaying mask.
from PyQt5 import QtCore, QtGui, QtWidgets
class VLine(QtWidgets.QFrame):
# a simple VLine, like the one you get from designer
def __init__(self):
super(VLine, self).__init__()
self.setFrameShape(self.VLine|self.Sunken)
class Grabber(QtWidgets.QWidget):
dirty = True
def __init__(self):
super(Grabber, self).__init__()
self.setWindowTitle('Screen grabber')
# ensure that the widget always stays on top, no matter what
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
# limit widget AND layout margins
layout.setContentsMargins(0, 0, 0, 0)
self.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# create a "placeholder" widget for the screen grab geometry
self.grabWidget = QtWidgets.QWidget()
self.grabWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
layout.addWidget(self.grabWidget)
# let's add a configuration panel
self.panel = QtWidgets.QWidget()
layout.addWidget(self.panel)
panelLayout = QtWidgets.QHBoxLayout()
self.panel.setLayout(panelLayout)
panelLayout.setContentsMargins(0, 0, 0, 0)
self.setContentsMargins(1, 1, 1, 1)
self.configButton = QtWidgets.QPushButton(self.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon), '')
self.configButton.setFlat(True)
panelLayout.addWidget(self.configButton)
panelLayout.addWidget(VLine())
self.fpsSpinBox = QtWidgets.QSpinBox()
panelLayout.addWidget(self.fpsSpinBox)
self.fpsSpinBox.setRange(1, 50)
self.fpsSpinBox.setValue(15)
panelLayout.addWidget(QtWidgets.QLabel('fps'))
panelLayout.addWidget(VLine())
self.widthLabel = QtWidgets.QLabel()
panelLayout.addWidget(self.widthLabel)
self.widthLabel.setFrameShape(QtWidgets.QLabel.StyledPanel|QtWidgets.QLabel.Sunken)
panelLayout.addWidget(QtWidgets.QLabel('x'))
self.heightLabel = QtWidgets.QLabel()
panelLayout.addWidget(self.heightLabel)
self.heightLabel.setFrameShape(QtWidgets.QLabel.StyledPanel|QtWidgets.QLabel.Sunken)
panelLayout.addWidget(QtWidgets.QLabel('px'))
panelLayout.addWidget(VLine())
self.recButton = QtWidgets.QPushButton('rec')
panelLayout.addWidget(self.recButton)
self.playButton = QtWidgets.QPushButton('play')
panelLayout.addWidget(self.playButton)
panelLayout.addStretch(1000)
def updateMask(self):
# get the *whole* window geometry, including its titlebar and borders
frameRect = self.frameGeometry()
# get the grabWidget geometry and remap it to global coordinates
grabGeometry = self.grabWidget.geometry()
grabGeometry.moveTopLeft(self.grabWidget.mapToGlobal(QtCore.QPoint(0, 0)))
# get the actual margins between the grabWidget and the window margins
left = frameRect.left() - grabGeometry.left()
top = frameRect.top() - grabGeometry.top()
right = frameRect.right() - grabGeometry.right()
bottom = frameRect.bottom() - grabGeometry.bottom()
# reset the geometries to get "0-point" rectangles for the mask
frameRect.moveTopLeft(QtCore.QPoint(0, 0))
grabGeometry.moveTopLeft(QtCore.QPoint(0, 0))
# create the base mask region, adjusted to the margins between the
# grabWidget and the window as computed above
region = QtGui.QRegion(frameRect.adjusted(left, top, right, bottom))
# "subtract" the grabWidget rectangle to get a mask that only contains
# the window titlebar, margins and panel
region -= QtGui.QRegion(grabGeometry)
self.setMask(region)
# update the grab size according to grabWidget geometry
self.widthLabel.setText(str(self.grabWidget.width()))
self.heightLabel.setText(str(self.grabWidget.height()))
def resizeEvent(self, event):
super(Grabber, self).resizeEvent(event)
# the first resizeEvent is called *before* any first-time showEvent and
# paintEvent, there's no need to update the mask until then; see below
if not self.dirty:
self.updateMask()
def paintEvent(self, event):
super(Grabber, self).paintEvent(event)
# on Linux the frameGeometry is actually updated "sometime" after show()
# is called; on Windows and MacOS it *should* happen as soon as the first
# non-spontaneous showEvent is called (programmatically called: showEvent
# is also called whenever a window is restored after it has been
# minimized); we can assume that all that has already happened as soon as
# the first paintEvent is called; before then the window is flagged as
# "dirty", meaning that there's no need to update its mask yet.
# Once paintEvent has been called the first time, the geometries should
# have been already updated, we can mark the geometries "clean" and then
# actually apply the mask.
if self.dirty:
self.updateMask()
self.dirty = False
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
grabber = Grabber()
grabber.show()
sys.exit(app.exec_())
please try this
from PyQt5.QtWidgets import QMainWindow, QApplication
from PyQt5.QtCore import Qt
import sys
class MainWindowExample(QMainWindow):
def __init__(self, parent=None):
try:
QMainWindow.__init__(self, parent)
self.setWindowFlags(Qt.CustomizeWindowHint | Qt.FramelessWindowHint)
self.setStyleSheet("border: 1px solid rgba(0, 0, 0, 0.15);")
except Exception as e:
print(e)
if __name__ == '__main__':
app = QApplication(sys.argv)
main_widow = MainWindowExample()
main_widow.show()
sys.exit(app.exec_())
Related
I've got a small example of the GUI I'm working on in Qt6, that has a problem switching palette colors (to switch from dark to light theme). When I apply my changes to QPalette to change the text color, they only work when the window is inactive. Weirdly, if I remove the font-family specification from the stylesheet then the color change works properly. This all works fine in Qt5 without any messing around.
On load, the GUI looks fine
After clicking the "Change Theme" button, it looks fine except that the text color setting that I change using Palette does not work (it's still black)
If I click on my desktop or a different window to make my GUI inactive, it then shows the correct text color (red)
Light - Working, Dark - Broken, Dark - Working
Any workaround suggestions (that make color and font both always work correctly) are welcome, but I'd love to know what I'm actually doing wrong here, and why it used to work in Qt5 and doesn't in Qt6! Thanks!
from PyQt6 import QtWidgets
from PyQt6.QtGui import QPalette, QColor, QFont
APP = QtWidgets.QApplication([])
class UiMain(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
central_widget = QtWidgets.QWidget(self)
self.setCentralWidget(central_widget)
tabs = QtWidgets.QTabWidget(central_widget)
vertical_layout_26 = QtWidgets.QVBoxLayout(central_widget)
vertical_layout_26.addWidget(tabs)
search_tab = QtWidgets.QWidget()
tabs.addTab(search_tab, "")
tabs.setTabText(tabs.indexOf(search_tab), "Search")
filter_group_box = QtWidgets.QGroupBox(search_tab)
filter_group_box.setTitle("Filters")
self.theme_btn = QtWidgets.QPushButton()
self.theme_btn.setText("Change Theme")
searchbar_layout = QtWidgets.QHBoxLayout(search_tab)
searchbar_layout.addWidget(QtWidgets.QLabel("asdf"))
searchbar_layout.addWidget(filter_group_box)
searchbar_layout.addWidget(self.theme_btn)
class View(UiMain):
def __init__(self):
super().__init__()
self.theme_btn.clicked.connect(self.change_theme) # noqa
# Create Palettes
self.light_palette = QPalette()
self.dark_palette = QPalette()
self.dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("red"))
# # This didn't help
# self.dark_palette.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.WindowText, QColor("red"))
# Create Stylesheets
self.style_light = """
* {font-family: 'Noto Sans';} /* REMOVING THIS LINE AVOIDS THE ISSUE, BUT THEN FONTS ARE WRONG INITIALLY */
QMainWindow {background-color: white;}
"""
self.style_dark = """
* {font-family: 'Noto Sans';}
QMainWindow {background-color: gray;}
"""
# Set initial theme
self.dark = False
APP.setPalette(self.light_palette)
APP.setStyleSheet(self.style_light)
self.show()
def change_theme(self):
"""Allow user to switch between dark and light theme"""
if self.dark:
self.dark = False
APP.setPalette(self.light_palette)
APP.setStyleSheet(self.style_light)
else:
self.dark = True
APP.setPalette(self.dark_palette)
APP.setStyleSheet(self.style_dark)
if __name__ == '__main__':
gui = View()
APP.exec()
I ran into this as well using PySide6 (occurs both on Windows and Linux).
In my case, the issue was that changing the palette using QApplication.setPalette after I had done anything with stylesheets resulted in the stylesheet not being properly applied to existing windows / objects or their children. Deleting the window and creating a new one worked as intended.
The issue (in my case) can be seen using something like the following. The application's palette would show my text color from my palette, but the window's palette shows the default black text color.
# Change theme
my_app.setStyleSheet(new_stylesheet)
my_app.setStyle(new_style)
my_app.setPalette(new_palette)
# self is a QMainWindow that was open when the theme was changed
print(QApplication.palette().color(QPalette.Text).name())
print(self.palette().color(QPalette.Text).name())
I do not know if this is a bug or not, but I was able to work around it without creating a new window instance by manually (and recursively) applying the palette to my existing window and its children using something like the following function
def change_palette_recursive(root: QWidget, palette: QPalette):
root.setPalette(palette)
for child in root.children():
if isinstance(child, QWidget):
change_palette_recursive(child, palette)
This should be called after changing the theme. It likely needs to be called on each open window (unless it is a child of another open window).
change_theme()
change_palette_recursive(existing_window, QApplication.palette())
I would generally consider this sort of thing bad form, but it is the only thing I have found (so far) that works.
I have a Main window, and I want to apply some QEvent in that window mouse events like move press... etc., My problem is after I created the app and main window I resized it, but when I call the rect() function which is from QWidget it actually gives me the default (0, 0, 100, 30), I want it to give me the size of the window.
class TestTools(unittest.TestCase):
app = QApplication([])
main_window = QMainWindow()
main_window.resize(958, 584)
scene = QGraphicsScene()
canvas = Canvas(scene, main_window)
print(canvas.rect()) # It Print (0, 0, 100, 30) Whereas it should be (0, 0, 958, 584)
def test():
pass # There is many functions am using but I don't think it is important to share it
And This is Canvas Class
class Canvas(QGraphicsView):
def __init__(self, scene, centralwidget):
super().__init__(scene, centralwidget)
self.scene = scene
background_color = Qt.white
self.pixmap_item: QGraphicsItem = self.scene.addPixmap(QPixmap(780, 580))
self.pixmap_item.setTransformationMode(Qt.FastTransformation)
So Where is the problem.
You're making an assumption based on wrong premises.
First of all, you're just creating a widget (the QGraphicsView) with a parent. Doing this will only make the widget a child of that parent, but without any size constraints: the widget is just "floating" inside its parent, so it will have a default size that usually is 100x30 for new child widgets (and 640x480 for widgets without parent set in the constructor)).
If you want to adjust the child to the parent it must be added to a layout. QMainWindow has its own (private) layout manager that uses a central widget to show the "main" content of the window, so if that QGraphicsView is going to be the only widget, you can just set that using main_window.setCentralWidget(canvas).
Then, a widget doesn't usually receive a resizeEvent until it's mapped the first time, unless it's manually resized. When it's shown the first time, any non previously layed out item receives the resize event. Since you're not showing the main window, no resize event is emitted, but even in that case, you resized the window before adding the child, so you would still see the same default size.
In order to ensure that the layout is properly computed even without showing the widget in which it is set, activate() must be explicitly called.
So, summing it up:
widgets must be added to a layout in order to adapt their size to their parent;
whenever a QMainWindow is used, setCentralWidget() must be used on the "main widget";
manual resizing should be done after adding children to the layout in order to ensure that their geometries adapt to the parent (if you do the opposite, some size policies could result in the parent resizing itself based on the children);
if the widget is not going to be shown, the layout must be manually activated;
class TestTools(unittest.TestCase):
app = QApplication([])
main_window = QMainWindow()
scene = QGraphicsScene()
canvas = Canvas(scene, main_window)
main_window.setCentralWidget(canvas)
main_window.resize(958, 584)
main_window.layout().activate()
print(canvas.rect())
I'm trying to create a circular button with an image.
So far I've been able to create a circular button and a button with an image background but I haven't been able to combine the two.
Here is my current code:
import sys
import PyQt5.QtWidgets
class Window(PyQt5.QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# setting title
self.setWindowTitle("Python ")
# setting geometry
self.setGeometry(100, 100, 600, 400)
# calling method
self.UiComponents()
# showing all the widgets
self.show()
# method for widgets
def UiComponents(self):
button = PyQt5.QtWidgets.QPushButton("CLICK", self)
button.setGeometry(200, 150, 100, 100)
# Background img and circular
button.setStyleSheet('''border-radius : 5;
border : 2px solid black
background-image : url(image.png);
''')
# adding action to a button
button.clicked.connect(self.clickme)
# action method
def clickme(self):
print("pressed")
App = PyQt5.QtWidgets.QApplication(sys.argv)
window = Window()
sys.exit(App.exec())
Qt documentation has a section exactly about this topic:
When styling a QPushButton, it is often desirable to use an image as the button graphic. It is common to try the background-image property, but this has a number of drawbacks: For instance, the background will often appear hidden behind the button decoration, because it is not considered a background. In addition, if the button is resized, the entire background will be stretched or tiled, which does not always look good.
It is better to use the border-image property, as it will always display the image, regardless of the background (you can combine it with a background if it has alpha values in it), and it has special settings to deal with button resizing.
So, you must ensure that you're using a square (not rectangular) image with the circle margins right at the edges, and use border-image instead of background-image (which tiles the image if it's smaller than the button size); note that you should not set any border in the stylesheet.
button.setStyleSheet('''
border-image: url(image.png);
''')
You obviously need to always use the same value for both the height and the width of the button, possibly by using setFixedSize(), otherwise if you add the button to a layout it will not be circular any more.
I'm trying to take a screenshot of the current active window in PyQt5. I know the generic method to take an screenshot of any window is QScreen::grabWindow(winID), for which winID is an implementation-specific ID depending on the window system. Since I'm running X and KDE, I plan to eventual use CTypes to call Xlib, but for now, I simply execute "xdotool getactivewindow" to obtain the windowID in a shell.
For a minimum exmaple, I created a QMainWindow with a QTimer. When the timer is fired, I identify the active window ID by executing "xdotool getactivewindow", get its return value, call grabWindow() to capture the active window, and display the screetshot in a QLabel. On startup, I also set my window a fixed 500x500 size for observation, and activate Qt.WindowStaysOnTopHint flag, so that my window is still visible when it's not in focus. To put them together, the implementation is the following code.
from PyQt5 import QtCore, QtGui, QtWidgets
import subprocess
class ScreenCapture(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
self.setFixedHeight(500)
self.setFixedWidth(500)
self.label = QtWidgets.QLabel(self)
self.timer = QtCore.QTimer(self)
self.timer.setInterval(500)
self.timer.timeout.connect(self.timer_handler)
self.timer.start()
self.screen = QtWidgets.QApplication.primaryScreen()
#QtCore.pyqtSlot()
def timer_handler(self):
window = int(subprocess.check_output(["xdotool", "getactivewindow"]).decode("ascii"))
self.screenshot = self.screen.grabWindow(window)
self.label.setPixmap(self.screenshot)
self.label.setFixedSize(self.screenshot.size())
if __name__ == '__main__':
app = QtWidgets.QApplication([])
window = ScreenCapture()
window.show()
app.exec()
To test the implementation, I started the script and clicked another window. It appears to work without problems if there is no overlap between my application window and the active window. See the following screenshot, when Firefox (right) is selected, my application is able to capture the active window of Firefox and display it in the QLabel.
However, the screenshot doesn't work as expected if there is an overlap between the application window and the active window. The window of the application itself will be captured, and creates a positive feedback.
If there is an overlap between the application window and the active window. The window of the application itself will be captured, and creates a positive feedback.
I've already disabled the 3D composite in KDE's settings, but the problem remains. The examples above are taken with all composite effects disabled.
Question
Why isn't this implementation working correctly when the application window and the active window are overlapped? I suspect it's an issue caused by some forms of unwanted interaction between graphics systems (Qt toolkit, window manager, X, etc), but I'm not sure.
Is it even possible solve this problem? (Note: I know I can hide() before the screenshot and show() it again, but it doesn't really solve this problem, which is taking a screenshot even if an overlap exists.)
As pointed out by #eyllanesc, it appears that it is not possible to do it in Qt, at least not with QScreen::grabWindow, because grabWindow() doesn't actually grab the window itself, but merely the area occupied by the window. The documentation contains the following warning.
The grabWindow() function grabs pixels from the screen, not from the window, i.e. if there is another window partially or entirely over the one you grab, you get pixels from the overlying window, too. The mouse cursor is generally not grabbed.
The conclusion is that it's impossible do to it in pure Qt. It's only possible to implement such a functionality by writing a low-level X program. Since the question asks for a solution "in Qt", any answer that potentially involves deeper, low-level X solutions are out-of-scope. This question can be marked as resolved.
The lesson to learn here: Always check the documentation before using a function or method.
Update: I managed to solve the problem by reading the window directly from X via Xlib. Somewhat ironically, my solution uses GTK to grab the window and sends its result to Qt... Anyway, you can write the same program with Xlib directly if you don't want to use GTK, but I used GTK since the Xlib-related functions in GDK is pretty convenient to demonstrate the basic concept.
To get a screenshot, we first convert our window ID to an GdkWindow suitable for use within GDK, and we call Gdk.pixbuf_get_from_window() to grab the window and store it in a gdk_pixbuf. Finally, we call save_to_bufferv() to convert the raw pixbuf to a suitable image format and store it in a buffer. At this point, the image in the buffer is suitable to use in any program, including Qt.
The documentation contains the following warning:
If the window is off the screen, then there is no image data in the obscured/offscreen regions to be placed in the pixbuf. The contents of portions of the pixbuf corresponding to the offscreen region are undefined.
If the window you’re obtaining data from is partially obscured by other windows, then the contents of the pixbuf areas corresponding to the obscured regions are undefined.
If the window is not mapped (typically because it’s iconified/minimized or not on the current workspace), then NULL will be returned.
If memory can’t be allocated for the return value, NULL will be returned instead.
It also has some remarks about compositing,
gdk_display_supports_composite has been deprecated since version 3.16 and should not be used in newly-written code.
Compositing is an outdated technology that only ever worked on X11.
So basically, it's only possible to grab a partially obscured window under X11 (not possible in Wayland!), with a compositing window manager. I tested it without compositing, and found the window is blacked-out when compositing is disabled. But when composition is enabled, it seems to work without problem. It may or may not work for your application. But I think if you are using compositing under X11, it probably will work.
from PyQt5 import QtCore, QtGui, QtWidgets
import subprocess
class ScreenCapture(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
self.setFixedHeight(500)
self.setFixedWidth(500)
self.label = QtWidgets.QLabel(self)
self.screen = QtWidgets.QApplication.primaryScreen()
self.timer = QtCore.QTimer(self)
self.timer.setInterval(500)
self.timer.timeout.connect(self.timer_handler)
self.timer.start()
#staticmethod
def grab_screenshot():
from gi.repository import Gdk, GdkX11
window_id = int(subprocess.check_output(["xdotool", "getactivewindow"]).decode("ascii"))
display = GdkX11.X11Display.get_default()
window = GdkX11.X11Window.foreign_new_for_display(display, window_id)
x, y, width, height = window.get_geometry()
pb = Gdk.pixbuf_get_from_window(window, 0, 0, width, height)
if pb:
buf = pb.save_to_bufferv("bmp", (), ())
return buf[1]
else:
return
#QtCore.pyqtSlot()
def timer_handler(self):
screenshot = self.grab_screenshot()
self.pixmap = QtGui.QPixmap()
if not self.pixmap:
return
self.pixmap.loadFromData(screenshot)
self.label.setPixmap(self.pixmap)
self.label.setFixedSize(self.pixmap.size())
if __name__ == '__main__':
app = QtWidgets.QApplication([])
window = ScreenCapture()
window.show()
app.exec()
Now it captures an active window perfectly, even if there are overlapping windows on top of it.
My problem is that collapsible QToolButton is acting weird in QScrollArea. It's not my first problem with collapsible QToolButton and at first it was my layout not stretching , so I added stretching(addStretch(1)) and it started working fine. Today, I tried to add QScrollArea to layout and then adding QToolButtons with widgets under it and same looking problem started again. So I thought that is again problem with stretching , so I was trying to search way of stretching QScrollArea and after looking, tried setting setSizePolicy(), sizeHint(), but it wouldn't fix problem. Can somebody help me find problem ?
More detailed explanation of problem : when you are expanding all collapsible QToolButton's first time there is no problem , but when you close them all and start opening again , starting from second QToolButton they start not opening from first few clicks. Also, I don't know if it's problem or not , but at first when you expand those buttons out of UI , they start shaking back and forward a little, basically not opening smoothly.
Here is code:
import random
from PySide2.QtGui import QPixmap, QBrush, QColor, QIcon, QPainterPath, QPolygonF, QPen, QTransform
from PySide2.QtCore import QSize, Qt, Signal, QPointF, QRect, QPoint, QParallelAnimationGroup, QPropertyAnimation, QAbstractAnimation
from PySide2.QtWidgets import QMainWindow, QDialog, QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QFrame, \
QSizePolicy, QGraphicsPixmapItem, QApplication, QRubberBand, QMenu, QMenuBar, QTabWidget, QWidget, QPushButton, \
QSlider, QGraphicsPolygonItem, QToolButton, QScrollArea, QLabel
extraDict = {'buttonSetA': ['test'], 'buttonSetB': ['test'], 'buttonSetC': ['test'], 'buttonSetD': ['test']}
class MainWindow(QDialog):
def __init__(self, parent=None):
QDialog.__init__(self, parent=parent)
self.create()
def create(self, **kwargs):
main_layout = QVBoxLayout()
tab_widget = QTabWidget()
main_layout.addWidget(tab_widget)
tab_extra = QWidget()
tab_widget.addTab(tab_extra, 'Extra')
tab_main = QWidget()
tab_widget.addTab(tab_main, 'Main')
tab_extra.layout = QVBoxLayout()
tab_extra.setLayout(tab_extra.layout)
scroll = QScrollArea()
content_widget = QWidget()
scroll.setWidget(content_widget)
scroll.setWidgetResizable(True)
#scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
tab_extra.layout.addWidget(scroll)
content_layout = QVBoxLayout(content_widget)
for name in extraDict.keys():
box = CollapsibleBox(name)
content_layout.addWidget(box)
box_layout = QVBoxLayout()
for j in range(8):
label = QLabel("{}".format(j))
color = QColor(*[random.randint(0, 255) for _ in range(3)])
label.setStyleSheet("background-color: {}; color : white;".format(color.name()))
label.setAlignment(Qt.AlignCenter)
box_layout.addWidget(label)
box.setContentLayout(box_layout)
content_layout.addStretch(1)
self.setLayout(main_layout)
class CollapsibleBox(QWidget):
def __init__(self, name):
super(CollapsibleBox, self).__init__()
self.toggle_button = QToolButton(text=name, checkable=True, checked=False)
self.toggle_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_button.setArrowType(Qt.RightArrow)
self.toggle_button.pressed.connect(self.on_pressed)
self.toggle_animation = QParallelAnimationGroup(self)
self.content_area = QScrollArea(maximumHeight=0, minimumHeight=0)
self.content_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.content_area.setFrameShape(QFrame.NoFrame)
lay = QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)
self.toggle_animation.addAnimation(QPropertyAnimation(self, b"minimumHeight"))
self.toggle_animation.addAnimation(QPropertyAnimation(self, b"maximumHeight"))
self.toggle_animation.addAnimation(QPropertyAnimation(self.content_area, b"maximumHeight"))
def on_pressed(self):
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(Qt.DownArrow if not checked else Qt.RightArrow)
self.toggle_animation.setDirection(QAbstractAnimation.Forward
if not checked
else QAbstractAnimation.Backward
)
self.toggle_animation.start()
def setContentLayout(self, layout):
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = (self.sizeHint().height() - self.content_area.maximumHeight())
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
animation.setDuration(500)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)
content_animation = self.toggle_animation.animationAt(self.toggle_animation.animationCount() - 1)
content_animation.setDuration(500)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
window = MainWindow()
window.setGeometry(500, 100, 500, 500)
window.show()
sys.exit(app.exec_())
Edit :
Here is link to a small video with problem I recorded , for more clear picture. (problem start on 0:12)
I noticed problem occurs only when I open all QToolBox and then close it one by one from below and then start to open them again.
The problem comes from the fact that when the collapsible box starts resizing, your mouse button will probably still be pressed, and if the button moves due to the scrolling, it will be moved "outside" the cursor position, resulting in the button release event being received outside the button area.
It's a common convention with buttons that if the user clicks on it but moves the cursor outside the button area and then releases the button, the button is not considered as clicked (or checked).
Also, checkable buttons become checked (see down property) when pressed, but are not toggled until the button is released, and if they are already checked, they become unchecked (as in "not down") when released (not when clicked), but, again, the toggled signal is is emitted if the mouse button is released within their geometry.
If you carefully look at your video, you can see that the unchecked buttons are gray, while when checked they have a light blue shade. When you try to uncheck them back, they still receive the pressed event (so the animation works as expected), but then they still remain pressed (blue-ish), and that's because they received the button release event outside their area. You can see the color difference when you try to click the second button after trying to expand it the second time.
So, when you click them again, they are already down, they receive the "pressed" signal, but since they're already down, they state is actually checked.
One would think that using the toggled signal would suffice, but this would mean to wait for the mouse button release (as explained before) and for similar cases is not that intuitive, since the user might prefer an immediate reaction to the mouse press, without waiting the release; this is another common convention for this kind of collapsible widgets.
The only solution I can think of is to create a fake release event and send it to the button as soon as the pressed signal is received. This will make the button "think" that the mouse has been released, thus applying the correct checked state.
def on_pressed(self):
checked = self.toggle_button.isChecked()
fakeEvent = QtGui.QMouseEvent(
QtCore.QEvent.MouseButtonRelease, self.toggle_button.rect().center(),
QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier)
QApplication.postEvent(self.toggle_button, fakeEvent)
This class constructor of QMouseEvent (there are 4 of them) is the simplest and you only need to set the local position based on the button rectangle; using the center ensures that the event is always received.
Finally, with postEvent the event is actually sent to the widget through QApplication (it's usually better to avoid sending an event directly to the receiver).
About the "shaking" widgets, that's probably due to the fact that you're using a parallel animation that sets the heights of both the contents and the container; while technically this happens in parallel, I believe that the problem comes from there are certain moments during which the two sizes are not "synchronized", and the layout is receiving (temporarily) unreliable data about their size and hints, probably due to the fact that the widget gets both minimum and maximum size, with the content area being resized afterwards.
After some tests I can tell that there's some slight difference between what could happen between the setMinimumHeight and setMaximumHeight.
def __init__(self, name):
# ...
self.toggle_animation.animationAt(0).valueChanged.connect(self.checkSizePre)
self.toggle_animation.animationAt(1).valueChanged.connect(self.checkSizePost)
def checkSizePre(self, value):
self.pre = self.y()
def checkSizePost(self, value):
QApplication.processEvents()
post = self.y()
if self.pre != post:
print('pre {} post {} diff {}'.format(self.pre, post, abs(self.pre - post)))
This results in a difference that varies between 0 and 6 pixel, which shows that setting those min/max values affects the overall positioning of the widgets. Obviously, those values are usually insignificant for when manually resizing a widget, but since resize events are always slightly delayed, in this case there's no sufficient time for the whole layout system to adjust everything without glitches.
Unfortunately, I can't think of a solution right now, sorry.