Skip to content

How to choose a style

A Qt session looks like the other programs on your computer, because it uses the style your platform gives every window. You can give it another look: napari's, an image viewer many labs already use, or qlementine's. The napari style comes with redsun, and you turn it on with one line; the qlementine style needs an extra, and has a section of its own below.

The positioner view in napari's dark theme

To see the style before you write a session, run the example with it:

redsun --example napari

Prerequisites

You need a QtSession. The style is a hook provider, so How to install hooks explains the two ways of installing it shown below.

Turn on the napari style

Install NapariStyle at the configure_application hook point:

configure_application: AsHook[NapariStyle]

Import it from redsun.qt.styles.napari.

hooks:
  configure_application: napari

napari is the short name of the style, so you don't write its class path.

The window then takes napari's colours and font sizes, and napari's own pictures for check boxes, arrows and other small controls. The buttons of the built-in views keep redsun's own icons, which take the theme's text colour, so they are light in the dark theme and dark in the light one. A view of your own gets the same icons from redsun.view.qt.icons.

Set the font size

The style uses napari's font size, 9 points, or 12 on macOS. Give font_size to choose your own, in points:

from typing import Annotated

from redsun import AsHook, Declare
from redsun.qt.styles.napari import NapariStyle


class MyApp(QtSession):
    configure_application: Annotated[AsHook[NapariStyle], Declare(font_size=10)]
hooks:
  configure_application:
    provider: napari
    kwargs:
      font_size: 10

Use it beside a provider of your own

configure_application takes several providers, so the style doesn't use the point up. List it with yours, and they run in the order you list them:

hooks:
  configure_application:
    - napari
    - provider: "mylab.app:MyIcon"

Switch between dark and light

The style follows the session's colour scheme, so it has a dark and a light theme and needs no setting of its own. The colour scheme button on the toolbar switches between them while the session runs, and the color_scheme key of the session file chooses where it starts. When the scheme is system and your platform doesn't say which it prefers, the style uses the light theme.

Undo the style

The napari style keeps the stylesheet the application had before. When the session shuts down, the style puts it back and stops following the colour scheme.

Where napari's files come from

The stylesheets and icons are napari 0.9.2's, copied unchanged into redsun.qt.styles, so napari doesn't need to be installed. They stay under napari's BSD 3-Clause licence, which is beside them. When the session builds, the style writes the icons of both themes, in their colours, to your user cache folder, and a file that hasn't changed isn't written again.

Use the qlementine style

qlementine is a style that draws every control itself, where the napari style is a stylesheet over your platform's:

The positioner view in qlementine's dark theme

redsun doesn't ship it, so install the extra for your Qt binding first:

pip install "redsun[qlementine-pyqt]"     # with PyQt6
pip install "redsun[qlementine-pyside]"   # with PySide6

Then install QlementineStyle at the same hook point:

from redsun import AsHook
from redsun.qt import QtSession
from redsun.qt.styles.qlementine import QlementineStyle


class MyApp(QtSession):
    configure_application: AsHook[QlementineStyle]
hooks:
  configure_application: qlementine

To see it first, run redsun --example qlementine.

The style follows the colour scheme as the napari style does. It changes the application's font as well as its colours. When the session shuts down it puts back the style, the font and the palette the application had.

qlementine draws every icon in the window in one colour, the colour of the control the icon is on. That keeps the icons of the built-in views readable in both themes, and it takes the colours away from an icon of your own that has several. To keep them on one widget, tell qlementine so for that widget:

import PyQt6Qlementine as qlementine  # or PySide6Qlementine

qlementine.QlementineStyle.setAutoIconColor(my_widget, qlementine.AutoIconColor.None_)

Before you choose it:

  • Its packages exist for Windows on x64, macOS on Apple silicon and Linux on x64 only.
  • qlementine-pyside installs the whole pyside6 at version 6.10.2, the one version its package accepts.
  • PyQt6-Qlementine is under the GPL-3.0 licence, as pyqt6 is. PySide6-Qlementine is under the MIT licence.
  • If the package is missing, the session logs the extra to install and starts with your platform's look. A session that sets strict stops instead.

Give qlementine your own theme

You can replace qlementine's dark theme, its light theme or both with a theme file of your own. Give the file's path as dark or light:

from typing import Annotated

from redsun import AsHook, Declare
from redsun.qt.styles.qlementine import QlementineStyle


class MyApp(QtSession):
    configure_application: Annotated[
        AsHook[QlementineStyle], Declare(dark="themes/my-dark.json")
    ]
hooks:
  configure_application:
    provider: qlementine
    kwargs:
      dark: themes/my-dark.json

The scheme you don't give keeps qlementine's own theme, so the colour scheme control still switches between the two. For one theme in both schemes, give the same file as dark and light.

A theme file is JSON. It needs a meta entry holding name, author and version, and every colour it leaves out takes qlementine's default, which is the light theme's. The easiest start is a copy of a built-in theme, which you then edit:

from pathlib import Path

import PyQt6Qlementine as qlementine  # or PySide6Qlementine

document = qlementine.Theme.makeDark().toJson()
Path("themes").mkdir(exist_ok=True)
Path("themes/my-dark.json").write_bytes(bytes(document.toJson()))

Write a colour as #rrggbb, or #rrggbbaa with its opacity last. qlementine uses its default for a value it can't read, so a misspelt colour leaves the default in place. The session logs a warning for a key qlementine doesn't know and for a value of the wrong kind, such as text where a number belongs.

The session reads a relative path from the folder the program starts in. If it can't read a file, it logs which one and starts with your platform's look, and a session that sets strict stops instead.

Turn qlementine's animations off

qlementine animates a control as it changes, such as a button you point at. Give animations: false to draw every change at once:

hooks:
  configure_application:
    provider: qlementine
    kwargs:
      animations: false

In a session class, write Declare(animations=False), as for a theme.

See also

  • How to install hooks shows how to write a style of your own, as a configure_application provider.
  • ADR 30 records why the files are copied.
  • ADR 32 records why qlementine has one extra per Qt binding.

The example in full

The whole script
"""The session of the guide "How to choose a style"."""

from __future__ import annotations

from collections.abc import Iterator  # noqa: TC003
from functools import cached_property
from typing import Any, ClassVar

from ophyd_async.core import (
    MovableLogic,
    StandardMovable,
    StandardReadable,
    StandardReadableFormat,
    soft_signal_rw,
)

from redsun import AsDevice, AsHook, AsPresenter, AsView, Link, links_between
from redsun.engine import RunEngine
from redsun.presenter import PositionerPresenter  # noqa: TC001
from redsun.qt import QtSession
from redsun.qt.styles.napari import NapariStyle  # noqa: TC001
from redsun.view.qt.builtins import PositionerView  # noqa: TC001


class MyAxis(StandardReadable, StandardMovable[float]):
    def __init__(self, name: str = "") -> None:
        with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
            self.position = soft_signal_rw(float, 0.0, units="um", precision=2)
        with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
            self.velocity = soft_signal_rw(float, 100.0, units="um/s")
        super().__init__(name=name)

    @cached_property
    def movable_logic(self) -> MovableLogic[float]:
        return MovableLogic(setpoint=self.position, readback=self.position)


class MyStage(StandardReadable):
    def __init__(self, name: str = "") -> None:
        self.x = MyAxis()
        self.y = MyAxis()
        self.add_readables([self.x, self.y])
        super().__init__(name=name)


class MyController:
    def __init__(self, name: str) -> None:
        self.name = name
        self.engine = RunEngine()


class MyApp(QtSession):
    config: ClassVar[dict[str, Any]] = {"session": "my-lab", "color_scheme": "dark"}

    configure_application: AsHook[NapariStyle]

    stage: AsDevice[MyStage]
    ctrl: AsPresenter[MyController]
    positioner: AsPresenter[PositionerPresenter]
    positioner_view: AsView[PositionerView]

    def wire(self) -> Iterator[Link]:
        yield from links_between(self.positioner_view, self.positioner)
        yield self.ctrl.engine.sig_locks_changed, self.positioner_view.set_locked
        yield self.ctrl.engine.sig_locks_changed, self.positioner.set_locked


if __name__ == "__main__":
    MyApp().run()