1

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).

3
  • @musicamante, hello again, I've done as you asked (removed earlier comments and severely cleaned up the original post. I don't know why i didn't make it this simple to begin with). By the way - as soon as you write "setstylesheet" the affected items lose all styling from the "setStyle", simply because the two cannot work together, hope that helps. You too may remove your earlier comments now to reduce clutter. Now if I could get inline pictures this might turn into a "OK" question actually worthy of someone's time :)
    – bfh47
    Commented Jul 3 at 23:27
  • Thanks for the editing, now it's much better. Here are some notes. 1. setStyleSheet() does not completely override setStyle(); in fact, setStyleSheet() creates an internal QStyleSheetStyle for all affected widgets, but still uses, to some extent, the QStyle set (for the widget or application wide), that's why it's normally suggested to use QApplication.setStyle('fusion') in order to get consistent/reliable QSS behavior. 2. While I understand the need for indentation and tidiness, I'd suggest you to use triple quotes when writing QSS, as it makes it easier to read and edit them. Commented Jul 4 at 0:40
  • 3. I'm afraid that there's no immediate solution for your issue, at least not by using QSS gradients alone; the main reason is that Qt is limited by the capabilities its gradients provide, and there is no possibility of "extending" them (the parent QGradient class is provided as to provide a common type and API, not for subclassing alternate gradients); the other reason is that QSS only allow gradients with relative coordinates (0.0-1.0), meaning that trying to hardcode "pixel-computed" stops will be effective only for very specific sizes. Commented Jul 4 at 0:40

1 Answer 1

0

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.

10
  • Thanks for the time and effort @musicamante, I will give a proper and full reply once I am back from work today.
    – bfh47
    Commented Jul 4 at 7:15
  • Hi again @musicamante, This is incredibly helpful, exactly what I need. Lots of minor details for me to work out, but no problem... Excluding the inner bevel, "true" vista button only uses 16 different colours for the horizontal gradient, this is constant, so stepping happens when the button is absurdly tall, your example is great for a "true recreation". Optionally I can hollow out a "png" such as yours, keeping only borders, and fill the inside with a bg-color (gradient) essentially allowing to produce "hd" buttons without stepping. Should I accept your answer? Do I need to update OP? Thx
    – bfh47
    Commented Jul 4 at 18:26
  • @bfh47 well, there's another alternative, but it completely prevents using QSS (at least for anything related to button painting, except for text display): use a QProxyStyle. If you know enough the colors used by the Vista buttons, you can directly use QPainter functions (a QBrush with a linear gradient can be set for the outer border pen). It's a bit more complex and less straightforward than having a collection of pixmaps, but it would also allow other aspects (possibly including animations). Commented Jul 4 at 20:09
  • @bfh47 If you plan on using the border image for the border only, you'd still need to create the possible combinations (pressed/unpressed, enabled/disabled, and maybe focused/previously pressed): do some tests to see which ones are actually necessary. Once that's done, you can create further separate selectors with the background colors only, with a small trick: padding: -2px; and background-clip: content; so that the padding will extend opposite to the border size, but won't show outside of the border image. That's up to you, though, I believe my answer already addresses enough :-) Commented Jul 4 at 20:13
  • @musicmante, You are right, in the long term, for any big project, needing pngs is really not "the way to do things", I only recently started learning, i set myself an initial task of "styling" something from the ground up. I even came across this "QProxyStyle" method (real styling) but it did look more daunting and less "accessible", so I thought to investigate the css route first. CSS, whilst more accessible, actually gets quite complicated quite quickly, so I may hit a wall at some point.
    – bfh47
    Commented Jul 4 at 23:01

Not the answer you're looking for? Browse other questions tagged or ask your own question.