I’ve something special planned for this blog post: This time, I will not only describe what I did in the last weeks, but I will go into some detail how I did it.
This blog post is the first one of a two-part-series where I demonstrate my work with practical examples. For the beginning, I don’t want to focus on specific applications (this will follow later!), but demonstrate basic HiDPI concepts with a minimalistic demo application. If you already had to do with HiDPI scaling in Qt once, you can probably skip this post. If you’re developing a Qt application that somewhere deals with
QPixmaps directly, this is the post for you!
To make those examples as simple and less verbose as possible, they are written in Python with PyQt. The concepts can be translated to C++/Qt 1-to-1.
A log of HiDPI handling stuff is already done transparently in the Qt Framework: Starting with Qt 5.6 the framework checks if the environment variable
QT_SCREEN_SCALE_FACTORS is set - this variable can be configured in a KDE world using the SystemSettings Display module. Before 5.6 the env variable
QT_DEVICE_PIXEL_RATIO (now deprecated!) was checked, but the newer approach not only allows configuring the scaling factor independently for each screen, but - in theory - also supports rational numbers. Why this only works in theory, you can read in a later blog post.
So this means that most of the stuff already works out of the box in Qt - unless an application has specifically opted out of enabling automatic scaling based on the pixel by setting the
Qt::AA_DisableHighDpiScaling attribute on a QApplication, like plasmashell does.
Dealing with pixmaps
Here is our requirement for our Proof of Concept: We want to draw a KDE logo, as clear and shiny as possible!
How would we do this? One possible solution could like this:
import sys from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * app = QApplication(sys.argv) class Window(QMainWindow): def __init__(self, parent=None): super(Window, self).__init__(parent) pixmap = QPixmap("./kde.png") label = QLabel(self) label.setPixmap(pixmap) self.setCentralWidget(label) def contextMenuEvent(self, event): action = QAction(QIcon("./kde.png"), "KDE", self) menu = QMenu(self) menu.addAction(action) menu.exec_(event.globalPos()) w = Window() w.show() app.exec_()
Starting this piece of code with scaling enabled (
python QT_SCREEN_SCALE_FACTORS=2 demo.py) results in the following output:
Huh, what’s going on here? Haven’t I said that Qt is able to figure out all basic scaling stuff on its own?
Yes, it does.. but we have to tell Qt explicitly to enable this setting via
Make QIcon::pixmap() generate high-dpi pixmaps that can be larger than the requested size. Such pixmaps will have devicePixelRatio() set to a value higher than 1. After setting this attribute, application code that uses pixmap sizes in layout geometry calculations should typically divide by devicePixelRatio() to get device-independent layout geometry.
This was made opt-in, because the returned pixmap can be larger than the requested size and therefore break existing code. Applications that opt-in to this feature need to make sure that they actually provide large enough pixmaps.
Enabling this …
… improves things a bit:
At least the icon in the context menu is now drawn without blurrying.
But what can we do with the main pixmap? Let’s simplify the code first:
l = QLabel() l.setWindowTitle("Pixmap") pixmap = QPixmap("./kde.png") l.setPixmap(pixmap) l.show()
Qt uses two coordinate systems internally: the device independent pixels coordinate system, which is used for the gemoetry of widgets and items; and the device pixels coordinate system, which translates to the rendered output, that corresponds to the display resolution. The ratio between the device independent and device pixel coordinate systems is the devicePixelRatio. QPixmap does not know what this ratio should be and therefore can’t take it into account automatically. For this reason the
devicePixelRatio also needs to be set:
This results in the correct scaling (
QSize layoutSize = image.size() / image.devicePixelRatio()) of the pixmap:
Side note: Have you noticed we used the devicePixelRatioF method? This is a new method since Qt 5.6, which returns a real value, instead of the old integer value of devicePixelRatio. Use the real variant, whenever possible!
Multiple monitors with different scale factors?
As I’ve seen while working on Okular and Gwenview, pixmaps often get cached.
Their implementation is vastly more complex than the example given below, but this implementation shows a simple caching strategy:
class Window(QMainWindow): def __init__(self, parent=None): super(Window, self).__init__(parent) self._width = 200 self._height = 200 self._pixmap = None self.resize(self._width, self._height) def paintEvent(self, event): if self._pixmap is None: dpr = self.devicePixelRatioF() pixmap = QPixmap("./kde-large.png") self._pixmap = pixmap.scaled(self._width*dpr, self._height*dpr, transformMode=Qt.SmoothTransformation) self._pixmap.setDevicePixelRatio(dpr) p = QPainter(self) p.drawPixmap(0, 0, self._pixmap) print("Pixmap DPR: %s / PaintDevice DPR: %s" % (self._pixmap.devicePixelRatioF(), self.devicePixelRatioF()))
This already looks quite good. But can you guess what could still go wrong here?
In Qt we can have different screens - even with different ratio between device independent and device pixels. If the second screen has a higher DPR value, the cached pixmap is too small.
Here is a demonstration:
As a workaround, we can use the highest DPR value of all screens for the caching of the pixmap. In Qt, this is available via
dpr = qApp.devicePixelRatio()
Returns the highest screen device pixel ratio found on the system. This is the ratio between physical pixels and device-independent pixels.
Use this function only when you don’t know which window you are targeting. If you do know the target window, use QWindow::devicePixelRatio() instead. Qt documentation
And now we finally have the shiny KDE logo in all its glory!