Search code examples
qtpyqt5qtstylesheets

Creating a nuance-styled button with css in pyqt5


I came across a button style which I like, I wanted to recreate it for use in pyqt5, using the QPushButton class, and styling it with css. If I can recreate it, I can change it's colours in cool ways.

In CSS terms, the button has a "solid" border of 1px all around, with a "border radius" of 2px, and I found it at 21px tall, however obviously it's resizeable. If anyone is wondering, it is the humble "Vista" button:

Vista button example (scaled to x2)

The Qt framework implements features based on the older CSS 2.1 standard, so somebody with knowledge of only CSS may know how this is done.

My inexperience with CSS is letting me down, unfortunately. Usually I can brute-force my way through a problem, and then tidy up and optimise my implementation, however here I have hit a bit of a stump.

90% of the look of the button can be created with a simple linear gradient for the background-color, however there is an "inner border" (bevel effect) of 1px which is flush with the real border and goes all the way around the button, I am having trouble re-creating this look.

The bevel is deceptively one color at the bottom and a different one at the top, and along the sides of the button there is a seperate linear gradient to link them together.

I tried messing with border styles, even with using gradient colours for the border, and I am not having any luck. Something that seemed useful called the "double" border style unfortunately was of no use to me, since you cannot control both border colours separately very well.

The below should explain it all.

No linear gradient in background color gets you this:

No linear gradient in background color

A simple linear gradient with a few stops gets this:

simple linear gradient example

A complex (crudely done) linear gradient can get top+bottom bevel:

very specific linear gradient

And this is a real button (not made by me), notice the sides:

The real button I'm trying to copy

Here is the code for the 3rd picture (over-engineered linear gradient with very many stops, don't laugh, this is a rough version - it can be done with less steps, but it is pixel perfect everywhere apart from the sides)

import sys
from PyQt5.QtWidgets import (QApplication, QWidget, QPushButton, QVBoxLayout)

app = QApplication([])

window_container = QWidget()
window_container.setWindowTitle("Buttons")
window_container.setMinimumSize(200,125)

window_container.setObjectName("window_container")

window_container.setStyleSheet("QPushButton {"
                               + "border-style: solid;"
                               + "border-width: 1px 1px 1px 1px;"
                               + "border-radius: 2px;"
                               + "border-color: #707070;"
                               + "font: 9;"
                               + "padding: 3px;"
                               + "background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, "
                               + "stop:0 #FCFCFC, "
                               + "stop:0.075 #FCFCFC, stop:0.111 #F2F2F2, stop:0.216 #F2F2F2, "
                               + "stop:0.217 #F1F1F1, stop:0.252 #F0F0F0, stop:0.320 #EFEFEF, "
                               + "stop:0.350 #EDEDED, stop:0.389 #ECECEC, stop:0.499 #EBEBEB, " 
                               + "stop:0.500 #DDDDDD, stop:0.556 #DBDBDB, stop:0.611 #DADADA, "
                               + "stop:0.667 #D8D8D8, stop:0.700 #D6D6D6, stop:0.750 #D4D4D4, "
                               + "stop:0.800 #D2D2D2, stop:0.870 #D1D1D1, stop:0.910 #CFCFCF, "
                               + "stop:0.945 #F3F3F3, "
                               + "stop:1.0 #F3F3F3);"
                               + "}"
                               )

button_1 = QPushButton("Top")

layout = QVBoxLayout()
layout.addWidget(button_1)

window_container.setLayout(layout)
window_container.show()

sys.exit(app.exec())

After a full analysis, I can provide a breakdown of what needs to be done, apologies for the crude diagrams:

Crude diagram

In this diagram, (c) is the main gradient fill (which doesn't share colours with anything else on)

Here's a legend for the above diagram to help make sense of things:

Legend for diagram

I don't know if I've reached the limits of what linear-gradient tool can do, or if there's something super easy I am missing, but I have every reason to believe this is possible, it's just rather specific.

The real trick here is that the "side" bevels need to be linear gradients connecting the top row of pixels inside the button to the bottom row, I can't figure that bit out.

Maybe someone finds this fun or a challenge, I am happy to try anything (within reason).


Solution

  • What you want to achieve cannot be done exclusively by using QSS (Qt Style Sheets) gradients:

    • Qt only provides 3 gradient types, which cannot be "mixed" or "extended"; the linear gradient only allows a mono-dimensional coloring, meaning that the "sides" cannot have different colors;
    • even ignoring the aspect above, attempting to use computed "pixel colors" in a gradient won't be effective: while buttons have a fixed height, that height is based on the OS (and the underlying style) and the font; if the user has a different font than yours or uses font scaling, what you thought as correct "pixel" ratios may result in blurred lines;

    So, is it impossible? Not completely.

    Historically, Qt has been using pixmap based styles in some cases (specifically on Windows, at least until they used the "QWindowsVistaStyle" for Windows <= 10, I don't know how the QWindows11Style introduced in Qt6.7 works yet).

    The solution, then, is relatively simple: use a pixmap taken from an "actual button" as the border-image property. The documentation about its Border Image explains (a bit obscurely) how it works:

    A border image is an image that is composed of nine parts (top left, top center, top right, center left, center, center right, bottom left, bottom center, and bottom right). When a border of a certain size is required, the corner parts are used as is, and the top, right, bottom, and left parts are stretched or repeated to produce a border with the desired size.
    See the CSS3 Draft Specification for details.

    Note: this is one of the few cases for which QSS use a CSS3 based implementation, while it normally follows the CSS2.1 specification only.

    This is also explained in the QPushButton and images section of the "Qt Style Sheets example" documentation. An extended explanation can be found in the Mozilla CSS docs, and can be simplified with the following:

    CSS border image slice example

    The border-image property will then contain the url of the image and values indicating the extent (in pixels) for the top, right, bottom and left edges. Since the corners are actually squares for a simple case like this, we can use the shortcut syntax; adding a single value will automatically extend that value for all corners, meaning that the value will be used for both the height and width of each corner.

    The only remaining thing we have is to get a proper source image for the button. If you have access to a Qt install on a Windows system using the intended style ("windowsvista"), it's relatively easy:

    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    app = QApplication([])
    btn = QPushButton()
    
    btn.resize(200, 200)
    
    pm = QPixmap(b.size())
    pm.fill(Qt.transparent)
    
    # by default render() also draws the background, let's explicitly avoid that
    btn.render(pm, flags=QWidget.RenderFlag(0))
    pm.save('button.png')
    

    Now we have a 200x200 image of the button (note that in the Windows Vista style, buttons normally have a 1 pixel margin around their borders).
    Let's open it in an image editing program.

    We need to do some copy/paste/resize magic in order to get an appropriate image: the style doesn't seem to use horizontal gradients, so we can cut out a portion from the vertical center, just leaving enough pixels for the corners of the border-image and the actual horizontal contents (areas 5 and 7); the vertical size remains unchanged, since it actually has some gradient data.

    Considering that the corners actually require 4x4 pixels, the result will be a 10x200 image: it has 4x4 corners, 2x4 top/bottom sides and 2x192 left/right sides, and it also contains the inner white border:

    cropped border image

    Now we just use the proper QSS syntax considering the above:

    window_container.setStyleSheet('''
        QPushButton {
            border-image: url("btn.png") 3;
            border: 3px solid transparent;
        }
    ''')
    

    Are we done? Not quite yet.

    When styling interactive widgets, we need to consider their states. Specifically, a button normally has the following states and their combinations:

    • enabled;
    • part of an active window;
    • focused;
    • pressed;
    • highlighted/hovered;

    Depending on the style, button setting and user interaction, a button could be, for example, unpressed and focused (pressing the left mouse button and moving away from the push button while the mouse button is still pressed). This means that we need to create related QPixmaps for all those combinations and related rules in the QSS.

    While creating QSS rules for the above would be mandatory anyway, creating those pixmaps may be a bit more complex.

    While one possibility is to create separate copies of the original pixmap and change their appearance, if you have access to a Windows system as above, it's theoretically easier: luckily, QStyle is implemented in a way that it doesn't really need real widgets in order to paint their appearance.

    I cannot provide the full code, but consider the following as a base:

    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    app = QApplication([])
    
    opt = QStyleOptionButton()
    opt.palette(app.palette())
    opt.rect = QRect(0, 0, 100, 200)
    
    style = app.style()
    
    pm = QPixmap(opt.rect.size())
    pm.fill(Qt.transparent)
    
    qp = QPainter(pm)
    style.drawControl(style.CE_PushButton, opt, qp)
    qp.end()
    pm.save('btn-disabled-off.png')
    
    pm.fill(Qt.transparent)
    qp.begin(pm)
    opt.state |= style.State_Enabled
    style.drawControl(style.CE_PushButton, opt, qp)
    qp.end()
    pm.save('btn-enabled-off.png')
    
    ...
    

    Note that I used an arbitrary width of 100 pixels: under a certain value (I don't know which), the left border is missing the white line. Using a width of 100 will require more image memory, I'll leave it to you to find the best approximation that also avoids unnecessary widths. By having proper mastering of QPainter capabilities you can apply dynamic cropping and save more memory friendly images.

    In reality, you should implement the above with the possible combinations that consider all the following QStyle.StateFlag flags:

    • State_Active (for the active window)
    • State_HasFocus
    • State_MouseOver
    • State_Sunken (when pressed, and under the mouse if pressed by it)
    • State_Raised (in theory not required, unless using the flat property, which would also require further iteration for the option.features switching QStyleOptionButton.None and QStyleOptionButton.Flat)

    This will end up with 8 to 16 possible combinations of images and related QSS rules (at least).

    Doing the above may be quite annoying, but don't believe that properly implementing QSS gradients for all those states (assuming they would have worked) would've been easier, especially considering the unavoidable issue of pixel-based gradients.