Writing a custom QStyle
It has been a long time that I wanted to write a total custom appearance for Qt applications. My experience with Qt’s CSS stylesheet was not very good, and I stumbled upon Phantom Style, by Andrew Richards, a custom-made Qt style that improves from Qt’s Fusion Style. Its look and performance is pretty good. So I decided to write my own, in a more modern fashion. And it’ll be the occasion to add dynamic theming. Here is a picture of what it looks like with the default colors, on Windows on a standard DPI screen:
What we’ll be handling, apart for (obviously) drawing widgets on screen:
- Animation system
- Dynamic Theming system, from
.json
files, with hot-reload. - Dynamic Pixmap Coloring, to ensure icons stay coherent with the theme colors.
- Out-of-widget-bounds Focus Border, à la macOS.
- Drop Shadows (coming soon).
QWidget and QPainter
What is QWidget?
QWidget
is the class that represents a element on the Graphical User interface. It has thus a visual representation on the screen, and receives events regularly to allow it to update its state. It must be re-drawn on the screen each time it receives an event that suggests a modification of its appearance. For instance, a button will repaint itself when it is pressed by the mouse pointer.
How does QWidget draw itself on the screen?
The drawing of a QWidget
is done when the method void paintEvent(QPaintEvent* evt)
is called. Since this method is virtual
, one’s can override it to customize the painting.
To draw, a QPainter
object is needed. This class has the necessary API to draw anything: rectangles, lines, text, images, paths. The common pattern to paint a QWidget
is the following:
|
|
Note that the painting is made by the CPU, with QPainter
’s Raster backend. This has potential implications on performance.
QStyle
What is QStyle
?
The QStyle
class is responsible for handling the look and feel of a Qt application. It provides methods to get sizing information, animation durations,… and last but not least: methods to paint widgets.
For each OS, QStyle
has been subclassed and the look and feel of the native widgets have been reproduced. You’ll find QWindowsStyle
, QMacStyle
, … and so on. Unfortunately, these class are part of Qt’s private API. If we want a custom look and feel, we have to subclass it too.
Note that there is one QStyle
instance running at a time, which is set on the application with the static method QApplication::setStyle(QStyle *style)
. Think of it like a singleton that is accessible from all the widgets.
Why not just using a CSS stylesheet?
Actually, when you use a CSS stylsheet (also known as QSS) on your Qt application, a dedicated QStyle
class is automatically created by the QApplication
to handle the painting based on the content of the CSS file: QStylesheetStyle
. It is a quick way to customize the style of the application.
However this has some drawbacks:
- Performance is not very good.
- Some drawing is wrong (strokes and border-radiuses, for instance).
- Qt uses a subset of CSS2, which is pretty old and limited. Important mising features are animations, shadows, and any kind of advanced graphical effect.
How does QStyle work?
In QWidget’s paintEvent method
When a QWidget
needs to be repainted, its method paintEvent()
will be called by Qt’s internal QWidgetRepaintManager
. This paintEvent()
method will get the QStyle
that is set on the QWidget
, then use it to draw the widget.
Since QStyle
is a singleton-like object, and does not know the QWidget
, it needs information about the widget’s state and features. This information is contained by the base class QStyleOption
, which is subclassed by all the different widgets. Every widget
has its corresponding QStyleOption
.
For example, a QPushButton
will draw itself like this (basically):
|
|
In QStyle
’s methods
QStyle
relies on const
methods: they don’t modify the input, apart from the QPainter
. They use their parameters in read-only mode, and only call internal draw primitives.
Every QWidget
is identified by an enum
value, and its current state by a QStyleOption
, so the QStyle::drawControl
wouldn’t even need to have the QWidget
as parameter. It is just there as a way of advanced customizing.
Also, QStyle
’s code has been properly separated between const
methods to draw and const
methods to get geometry to draw at.
|
|
We’ll see the internal workings of a QStyle
in the next part.
A custom QStyle
Methods to implement
We’ll subclass QCommonStyle
to avoid handling generic behaviors and only provide necessary code.
Qt divides widgets’ visual appearance into smaller interactive parts: subcontrols. These interactive parts are themselves divided into smaller parts: subelements. QStyle
provides primitive dedicated methods to draw these elements, instead of a huge methods that would draw the complete widget. These smaller parts are identified by the following enums. It is not clear how they are classified, though.
Drawing
The enums
that define the parts to draw are:
PrimitiveElement
: Basic elements used by multiple widgets. Exemple:PE_IndicatorCheckBox
is the check mark of aQCheckBox
but is also used for checkable items in aQListView
.ControlElement
: A part of a specific widget. Example:CE_PushButtonBevel
is the part that you click on aQPushButton
.SubElement
: A sub-part of a specific widget. Example:SE_PushButtonContents
is the foreground (text+icon) of aQPushButton
.ComplexControl
: Widgets composed by other interactive elements (but not widgets). Example:CC_SpinBox
, which corresponds toQSpinBox
has a text field and up/down buttons.SubControl
: A widget that is part of aComplexControl
.
We need to implement these virtual methods, to handle the possible values of the aforementioned enums (Note: the w
and p
mean that it takes a const QPainter*
and a const QWidget*
as parameters):
Method | Role |
---|---|
void drawPrimitive(PrimitiveElement e, const QStyleOption* o, w, p) const |
Draws a basic, unit, element (e.g. background of a button). |
void drawControl(ControlElement e, const QStyleOption* o, w, p) const |
Draws a QWidget , by calling drawPrimitive() . |
void drawComplexControl(ComplexControl c, const QStyleOptionComplex* o, w, p) const |
Draws a QWidget composed by one or several interactive parts. |
Sizing
Not only we need to draw the widgets, but we also need to provide size and position information to draw them. Some enums help us identify which part to size.
PixelMetric
: Used to calculate sizes for the contents of various widgets.ContentsType
: Used to calculate sizes for the contents of various widgets.
These enums
are used by these methods:
Method | Role |
---|---|
int pixelMetric(PixelMetric m, const QStyleOption* o, w) const |
Gives the size of a part of a widget like margins, padding, icon sizes, etc. |
QSize sizeFromContents(ContentsType c, const QStyleOption* o, const QSize& s, w) const |
Gives the QWidget ’s size based on its content (text, icon, etc.). |
QRect subControlRect(ComplexControl c, const QStyleOptionComplex* o, SubControl sc, w) const |
Gives the coordinates and size where to draw an inner interactive part of a complex QWidget . |
QRect subElementRect(SubElement s, const QStyleOption* o, w) const |
Gives the coordinates and size where to draw a part of a QWidget . |
General-purpose
More general-purpose enums and methods:
StandardPixmap
: General-purpose icons (success, warning, error, etc.) and images.StyleHint
: Identifies a size, a mode or anything else, that is used to customize the behavior and does not fit anywhere else.
Method | Role |
---|---|
SubControl hitTestComplexControl(ComplexControl cc, const QStyleOptionComplex* o, const QPoint& p, w) const |
Returns which sub-widget has been hit by the mouse. |
int styleHint(StyleHint s, const QStyleOption* o, w, QStyleHintReturn *hret = nullptr) const |
Provides specific values (such as border radius). hret is used if value is more complicated than just an int . |
QPixmap standardPixmap(StandardPixmap sp, const QStyleOption* o, w) const |
Used to give custom icons to widgets, dialogs, etc. |
void polish(QWidget* w) |
Called before w is painted for the first time by the QStyle . |
void unpolish(QWidget* w) |
Called before w is destroyed or when its QStyle has changed. |
Theming support
Qt has QPalette
, its built-in theming object. Yet, this class is not powerful enough to handle proper modern theming, like we see with current CSS frameworks.
We can create a Theme
class to replace QPalette
, that would contain all basic values like colors, sizes, animation durations, etc.
|
|
A Theme
object can be created from JSON (or any data-oriented text files). JSON is conveniant because Qt has everything we need to parse it (QJsonDocument
, QJsonObject
…). Here is an example:
|
|
Note that you have to write parsers to create Qt objects like QColor
, QSize
, etc. They won’t be all shown here, as they’re pretty straightforward to write, and will depend on your own design and data specifications. I had to write functions like std::optional<QColor> tryGetColorFromHexaString(QString const& str)
, as instance:
|
|
Our QStyle
can have a current Theme
that it will use to paint. It even allows to change theme at run time, and having everything re-draw.
|
|
Animations
Since QStyle
only gets information about the widget’s state with QStyleOption
, it does not have any notion of time, which is necessary for animations. We need a way to store this state.
Qt’s own private implementation
Qt’s sources show us that the workaround they used is to keeps a HashMap in the QStyle
, associating a QObject*
to a dedicated object used for handling its animation: QStyleAnimation
. It can animates any QObject
and not only its subclass QWidget
.
|
|
When the animation is running and a value (e.g. a QColor
) has been updated, it needs a way to trigger a visual update of the QWidget
. This is done by sending an event synchronously to the QWidget
|
|
Unfortunately, QStyleAnimation
is not part of Qt’s public API. We’ll follow the same mechanism by re-making this system from scratch for our own QStyle
.
Custom implementation
This is what our system needs to do:
-
Registering all newly created
QWidget
and their associatedWidgetAnimator
in the HashMap. We’ll do this lazily, only as needed, when an animation is requested. -
Triggering animations when the
QWidget
’s state changes. We’ll do this lazily, directly in the paint methods. -
Providing animated (i.e. interpolated) values that our
QStyle
will use to paint theQWidget
.
WidgetAnimator
is a class that stores all the animations set on a QWidget
. For the animations, we have two options:
- Create our own implementation.
- Rely on the already-existing Qt implementation that is
QVariantAnimation
.
The second option might be not the best, performance-wise, but it’s sufficient. Consequently, an animation is handled by the class WidgetAnimation<T>
that is a typed wrapper over QVariantAnimation
. Typing is useful so we won’t have to write the conversion code lines every time. WidgetAnimation<T>
will trigger
a new repaint for the QWidget
every time the animation value has changed.
|
|
We need a class that creates WidgetAnimator
s when needed: WidgetAnimationManager
.
To animate a property, we have two options:
- Rely on an
enum
calledStyleProperty
that containsBackgroundColor
,ForegroundColor
, etc. and we would store corresponding animations into an hash map. - Directly add the methods in the class.
We chose the second option as it allow us to have typed methods. A MACRO
(not shown here) may help, because the code is the same for all properties.
|
|
This method is called at every paint, i.e. for every frame that needs to be drawn.
- If the target value changes, it restarts the animation, with the current one as starting value.
- If the target value doesn’t change, it returns the current interpolated value.
Out-of-widget-bounds focus border
As you may already known, it is only possible to drawn within the QWidget
’s bounds. The QPainter
is clipped to its limit, thus can’t drawn outside of the QWidget
coordinates.
If we want a focus border that draws outside the limits of the QWidget
, we can simply use Qt’s QFocusBorder
. Before a QWidget
draws itself, the method QStyle::polish(QWidget* w)is called, allowing us to install any kind of behavior on the widget. We use this to instanciate a
QFocusBorder` on the widgets we want to have this macOS-like focus style.
|
|
This class will call QStyle::drawControl(CE_FocusFrame, styleOpption, widget)
to draw itself. We can then check its parent QWidget
’s type to determine the QRect
of the focus border. We need to do this for every type that has a QFocusBorder
.
|
|
Cheatsheet for QWidgets and their QStyle enum values
Each QWidget has its dedicated enum
values. Here is a guide to help you with this mess.
Basic Controls
QPushButton
PM_ButtonMargin
,PM_ButtonDefaultIndicator
,PM_MenuButtonIndicator
,PM_ButtonShiftHorizontal
,PM_ButtonShiftVertical
,PM_ButtonIconSize
CT_PushButton
SE_PushButtonContents
,SE_PushButtonBevel
,SE_PushButtonFocusRect
PE_PanelButtonBevel
,PE_IndicatorButtonDropDown
,PE_FrameButtonBevel
CE_PushButton
,CE_PushButtonBevel
,CE_PushButtonLabel
QCheckBox
PM_CheckBoxLabelSpacing
,PM_IndicatorWidth
,PM_IndicatorHeight
CT_CheckBox
SE_CheckBoxContents
,SE_CheckBoxClickRect
,SE_CheckBoxIndicator
,SE_CheckBoxFocusRect
PE_IndicatorCheckBox
CE_CheckBox
,CE_CheckBoxLabel
QRadioButton
PM_RadioButtonLabelSpacing
,PM_ExclusiveIndicatorWidth
,PM_ExclusiveIndicatorHeight
CT_RadioButton
SE_RadioButtonContents
,SE_RadioButtonClickRect
,SE_RadioButtonIndicator
,SE_RadioButtonFocusRect
PE_IndicatorRadioButton
CE_RadioButton
,CE_RadioButtonLabel
QProgressBar
PM_ProgressBarChunkWidth
SE_ProgressBarGroove
,SE_ProgressBarContents
,SE_ProgressBarLabel
CE_ProgressBar
,CE_ProgressBarGroove
,CE_ProgressBarContents
,CE_ProgressBarLabel
PE_IndicatorProgressChunk
CT_ProgressBar
QMenuBar
PE_PanelMenuBar
CE_MenuBarItem
,CE_MenuBarEmptyArea
PM_MenuBarPanelWidth
,PM_MenuBarItemSpacing
,PM_MenuBarHMargin
,PM_MenuBarVMargin
CT_MenuBar
,CT_MenuBarItem
QMenu
PE_IndicatorMenuCheckMark
,PE_PanelMenu
CE_MenuItem
,CE_MenuScroller
,CE_MenuTearoff
,CE_MenuEmptyArea
,CE_MenuHMargin
,CE_MenuVMargin
PM_SubMenuOverlap
,PM_MenuScrollerHeight
,PM_MenuTearoffHeight
,PM_MenuDesktopFrameWidth
,PM_MenuPanelWidth
,PM_MenuHMargin
,PM_MenuVMargin
CT_Menu
,CT_MenuItem
QToolBar
PE_PanelToolBar
,PE_IndicatorToolBarHandle
,PE_IndicatorToolBarSeparator
CE_ToolBar
PM_ToolBarFrameWidth
,PM_ToolBarHandleExtent
,PM_ToolBarItemMargin
,PM_ToolBarItemSpacing
,PM_ToolBarSeparatorExtent
,PM_ToolBarExtensionExtent
SE_ToolBarHandle
QLineEdit
PE_PanelLineEdit
PM_TextCursorWidth
SE_LineEditContents
CT_LineEdit
QTreeView, QListView, QTableView
PE_IndicatorBranch
,PE_IndicatorItemViewItemCheck
,PE_IndicatorHeaderArrow
,PE_IndicatorColumnViewArrow
,PE_PanelItemViewItem
,PE_PanelItemViewRow
CE_Header
,CE_HeaderSection
,CE_HeaderLabel
,CE_ItemViewItem
,CE_HeaderEmptyArea
PM_TreeViewIndentation
,PM_HeaderDefaultSectionSizeHorizontal
,PM_HeaderDefaultSectionSizeVertical
,PM_HeaderMarkSize
,PM_HeaderGripMargin
,PM_HeaderMargin
SE_HeaderArrow
,SE_HeaderLabel
,SE_ItemViewItemCheckIndicator
,SE_TreeViewDisclosureItem
,SE_ItemViewItemDecoration
,SE_ItemViewItemText
,SE_ItemViewItemFocusRect
CT_HeaderSection
,CT_ItemViewItem
QToolTip
PE_PanelTipLabel
QTabBar
PE_FrameTabBarBase
,PE_IndicatorTabTearLeft
,PE_IndicatorTabTearRight
,PE_IndicatorTabClose
CE_TabBarTab
,CE_TabBarTabShape
,CE_TabBarTabLabel
PM_TabBarTabOverlap
,PM_TabBarTabHSpace
,PM_TabBarTabVSpace
,PM_TabBarBaseHeight
,PM_TabBarBaseOverlap
,PM_TabBarScrollButtonWidth
,PM_TabBarTabShiftHorizontal
,PM_TabBarTabShiftVertical
,PM_TabBar_ScrollButtonOverlap
,PM_TabCloseIndicatorWidth
,PM_TabCloseIndicatorHeight
SE_TabBarTearIndicatorLeft
,SE_TabBarTearIndicatorRight
,SE_TabBarScrollLeftButton
,SE_TabBarScrollRightButton
,SE_TabBarTabLeftButton
,SE_TabBarTabRightButton
,SE_TabBarTabText
CT_TabBarTab
QStatusBar
PE_PanelStatusBar
,PE_FrameStatusBar
,
QSplitter
CE_Splitter
PM_SplitterWidth
CT_Splitter
QFocusFrame
CE_FocusFrame
QMessagaBox
- PM_MessageBoxIconSize
Complex Controls
QSlider
PM_SliderThickness
,PM_SliderControlThickness
,PM_SliderLength
,PM_SliderTickmarkOffset
,PM_SliderSpaceAvailable
SC_SliderGroove
,SC_SliderHandle
,SC_SliderTickmarks
SE_SliderFocusRect
CC_Slider
CT_Slider
QDial
SC_DialHandle
,SC_DialGroove
,SC_DialTickmarks
CC_Dial
QScrollBar
CE_ScrollBarAddLine
,CE_ScrollBarSubLine
,CE_ScrollBarAddPage
,CE_ScrollBarSubPage
,CE_ScrollBarSlider
,CE_ScrollBarFirst
,CE_ScrollBarLast
PM_ScrollBarExtent
,PM_ScrollBarSliderMin
,PM_ScrollView_ScrollBarSpacing
,PM_ScrollView_ScrollBarOverlap
SC_ScrollBarAddLine
,SC_ScrollBarSubLine
,SC_ScrollBarAddPage
,SC_ScrollBarSubPage
,SC_ScrollBarFirst
,SC_ScrollBarLast
,SC_ScrollBarSlider
,SC_ScrollBarGroove
CC_ScrollBar
CT_ScrollBar
QComboBox
PM_ComboBoxFrameWidth
CT_ComboBox
SE_ComboBoxFocusRect
CE_ComboBoxLabel
SC_ComboBoxArrow
,SC_ComboBoxEditField
,SC_ComboBoxFrame
,SC_ComboBoxListBoxPopup
CC_ComboBox
QDockWidget
PE_IndicatorDockWidgetResizeHandle
CE_DockWidgetTitle
PM_DockWidgetTitleBarButtonMargin
,PM_DockWidgetSeparatorExtent
,PM_DockWidgetHandleExtent
,PM_DockWidgetFrameWidth
,PM_DockWidgetTitleMargin
SE_DockWidgetFloatButton
,SE_DockWidgetTitleBarText
,SE_DockWidgetCloseButton
,SE_DockWidgetIcon
QToolButton
PE_PanelButtonTool
,PE_FrameButtonTool
CE_ToolButtonLabel
SC_ToolButton
,SC_ToolButtonMenu
SE_ToolBoxTabContents
CC_ToolButton
CT_ToolButton
QSpinBox
PE_IndicatorSpinDown
,PE_IndicatorSpinUp
,PE_IndicatorSpinMinus
,PE_IndicatorSpinPlus
PM_SpinBoxFrameWidth
,PM_SpinBoxSliderHeight
SC_SpinBoxUp
,SC_SpinBoxDown
,SC_SpinBoxFrame
,SC_SpinBoxEditField
CC_SpinBox
CT_SpinBox
QGroupBox
SC_GroupBoxFrame
,SC_GroupBoxLabel
,SC_GroupBoxCheckBox
,SC_GroupBoxContents
CC_GroupBox
CT_GroupBox
QProxyStyle
A QProxyStyle
is a way to modify the behavior of a QStyle
by dynamically overriding painting or other specific style behavior. Instead of subclassing a QStyle
-derived class, you need a class that inherits QProxyStyle
.
The QStyle
checks if it has a proxy before each method call, and calls it if so. However, as said in the documentation, there is no guarantee that QStyle::proxy()
will be called by the QStyle
you override. User-defined or system-controlled styles may ignore their proxy.
Currently, my QStyle doesn’t support the use of a proxy style.
Coming soon…
Usage
To use a QStyle
on a QApplication
, it can be done like this:
|
|
Final Thoughts
It was a long and tedious task, but now I have more control over what is displayed by the Qt application. I also see the limits of Qt styling system. Some widgets were particularly hard to get right, such as QTabBar
, because Qt’s private code calls the QStyle
methods in a specific order that we can’t modify. For the QTabBar
, as instance, the tabs are drawn in a specific order and it will impact the shape you may give them.
I made sure that my QStyle
library does not contain any company-specific code, so the lib is totally generic and can be used on any Qt application.
I plan to improve it progressively and open-source it, so the Qt community is able to benefit it.