Source code for pymodaq_gui.utils.styling

from pathlib import Path
from typing import Optional, Union
import dataclasses

from qt_themes import get_theme

import pyqtgraph as pg
import qt_themes
from pyqtgraph import mkColor
from qtpy import QtCore, QtGui, QtWidgets

from pymodaq_gui.resources.material_icons import MaterialIcon
from pymodaq_gui.utils.widgets.painter_utils import draw_shape
from pymodaq_utils.config import GlobalConfig as Config
from pymodaq_utils.enums import StrEnum

config = Config()
# Theme configured at startup. Kept for backward compatibility: code that needs
# the theme actually applied to the application (it can change at runtime, see
# pymodaq_gui.qt_utils.apply_theme) should call get_current_theme().
theme = qt_themes.get_theme(config('gui', 'style', 'theme')[0])

[docs] class ThemeColor(StrEnum): """The colours of a qt_themes theme, by name. Pass these (or the equivalent plain strings, e.g. ``'green'``) wherever a colour should follow the theme, e.g. ``add_action(..., icon_color=ThemeColor.GREEN)``: they are resolved against the theme applied when the icon is (re)built, so they follow a theme change. A QColor or a hex string is a fixed colour. Members behave as plain ``str`` (``ThemeColor.GREEN == 'green'``). """ # hues MAGENTA = 'magenta' RED = 'red' ORANGE = 'orange' YELLOW = 'yellow' GREEN = 'green' CYAN = 'cyan' BLUE = 'blue' # text, from strongest to most muted TEXT = 'text' SUBTEXT1 = 'subtext1' SUBTEXT0 = 'subtext0' OVERLAY2 = 'overlay2' OVERLAY1 = 'overlay1' OVERLAY0 = 'overlay0' # backgrounds, from lightest to darkest surface SURFACE2 = 'surface2' SURFACE1 = 'surface1' SURFACE0 = 'surface0' BASE = 'base' MANTLE = 'mantle' CRUST = 'crust' # accents PRIMARY = 'primary' SECONDARY = 'secondary'
# Lookup order of as_theme_role, hues first: when a colour matches several roles # (a theme's primary is often also its blue), the hue is the likely intent. THEME_COLOR_ROLES = tuple(ThemeColor.values())
[docs] def get_current_theme() -> Union[qt_themes.Theme, None]: """Return the theme applied to the running QApplication, else the configured one.""" current = get_theme() if QtWidgets.QApplication.instance() is not None else None return current if current is not None else theme
[docs] def as_theme_role(color: Union[QtGui.QColor, str, None]) -> Union[QtGui.QColor, str, None]: """Return the :class:`ThemeColor` of the current theme equal to *color*, else *color*. Compatibility shim: colours that follow the theme should be given by name (:class:`ThemeColor`). Code that passes a colour already resolved from the theme (``get_theme().green``) gets it mapped back to its name, so that it still follows the theme. Strings and colours that are not part of the theme are returned unchanged. """ if not isinstance(color, QtGui.QColor): return color current = get_current_theme() if current is None: return color for role in THEME_COLOR_ROLES: role_color = getattr(current, role, None) if isinstance(role_color, QtGui.QColor) and role_color == color: return ThemeColor(role) return color
[docs] def make_shape_icon( shape: str = "circle", filled: bool = True, size: int = 12, width: int = None, height: int = None, pen: QtGui.QPen = None, brush: QtGui.QBrush = None, **kwargs, ) -> QtGui.QIcon: """Return a small solid or outlined shape as a QIcon, using pyqtgraph's mkPen/mkBrush. Parameters ---------- shape: Shape of the icon: 'circle', 'triangle', 'square', 'rectangle', or 'diamond'. filled: If True, the shape is filled; if False, only the outline is drawn. size: Diameter of the circle, side length of the square/diamond, or default side for triangle/rectangle in pixels (default 12). width: Width of the rectangle (if shape is 'rectangle'). If None, defaults to `size`. height: Height of the rectangle (if shape is 'rectangle'). If None, defaults to `size`. pen: QPen for the outline (e.g., pg.mkPen('blue', width=2)). brush: QBrush for filling the shape (e.g., pg.mkBrush('red')). **kwargs: Optional arguments for creating pen/brush if not provided: - color: str (default: 'black') - pen_width: int (default: 1) - pen_style: str (e.g., 'solid', 'dashed', 'dotted', 'dashdot', 'dashdotdot') - brush_color: str (default: same as color) """ if width is None: width = size if height is None: height = size # Map string pen styles to Qt.PenStyle enums style_map = { "solid": QtCore.Qt.PenStyle.SolidLine, "dashed": QtCore.Qt.PenStyle.DashLine, "dotted": QtCore.Qt.PenStyle.DotLine, "dashdot": QtCore.Qt.PenStyle.DashDotLine, "dashdotdot": QtCore.Qt.PenStyle.DashDotDotLine, "none": QtCore.Qt.PenStyle.NoPen, } # Use the maximum of width/height to ensure the pixmap is large enough pixmap_size = max(width, height) pixmap = QtGui.QPixmap(pixmap_size, pixmap_size) pixmap.fill(QtCore.Qt.GlobalColor.transparent) with QtGui.QPainter(pixmap) as painter: painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) # Create pen and brush if not provided if pen is None: color = kwargs.get("color", "black") pen_width = kwargs.get("pen_width", 1) pen_style = kwargs.get("pen_style", "solid") style = style_map.get(pen_style.lower(), QtCore.Qt.PenStyle.SolidLine) pen = pg.mkPen(color, width=pen_width) pen.setStyle(style) if brush is None and filled: brush_color = kwargs.get("brush_color", kwargs.get("color", "black")) brush = pg.mkBrush(brush_color) # Set brush and pen painter.setBrush(brush if filled else QtCore.Qt.BrushStyle.NoBrush) painter.setPen(pen if not filled else QtCore.Qt.PenStyle.NoPen) rect = QtCore.QRectF(1, 1, width - 2, height - 2) draw_shape(painter, shape, rect) painter.end() return QtGui.QIcon(pixmap)
[docs] def create_font(font_name=None, font_size=None, isbold=False, isitalic=False, ) -> QtGui.QFont: font = QtGui.QFont() if font_name is not None: font.setFamily(font_name) if font_size is not None: font.setPointSize(font_size) font.setBold(isbold) font.setItalic(isitalic) return font
[docs] def create_color(icon_color: Optional[Union[QtGui.QColor, bytes, str]]) -> Union[QtGui.QColor, None]: """Return a QColor from a QColor, a theme role name (resolved against the theme currently applied, see get_current_theme) or any string QColor accepts.""" if icon_color is not None: if isinstance(icon_color, str): try: icon_color = getattr(get_current_theme(), icon_color) except AttributeError: icon_color = QtGui.QColor(icon_color) if not icon_color.isValid(): icon_color = None return icon_color # type: ignore[return-value]
[docs] def transform_icon(icon: QtGui.QIcon, transform: QtGui.QTransform) -> QtGui.QIcon: """Return a new QIcon with all pixmaps transformed by transform. Parameters ---------- icon: QtGui.QIcon The icon to transform. transform: QtGui.QTransform The transform to apply to the icon. """ new_icon = QtGui.QIcon() sizes = icon.availableSizes() or [QtCore.QSize(s, s) for s in (20, 40)] modes = (QtGui.QIcon.Mode.Normal, QtGui.QIcon.Mode.Disabled, QtGui.QIcon.Mode.Active, QtGui.QIcon.Mode.Selected) for size in sizes: for mode in modes: for state in (QtGui.QIcon.State.Off, QtGui.QIcon.State.On): # Call QIcon's own pixmap() rather than icon.pixmap(): some icon # sources (e.g. MaterialIcon) override pixmap() in Python and # recompute from a default colour instead of returning the # already-coloured pixmap cached via addPixmap()/set_color(). px = QtGui.QIcon.pixmap(icon, size, mode, state) if not px.isNull(): if type(px) is not QtGui.QPixmap: px = QtGui.QPixmap.fromImage(px) px_transformed = px.transformed(transform) if type(px) is not QtGui.QPixmap: px_transformed = QtGui.QPixmap.fromImage(px.transformed(transform)) new_icon.addPixmap( px_transformed, mode, state, ) return new_icon
def _translate_icon(icon: QtGui.QIcon, x: int = 0, y: int = 0) -> QtGui.QIcon: """Return a new QIcon with all pixmaps translated by x and y pixels. Parameters ---------- icon: QtGui.QIcon The icon to translate. x: int The number of pixels to translate the icon in the x direction. y: int The number of pixels to translate the icon in the y direction. """ transform = QtGui.QTransform().translate(x, y) return transform_icon(icon, transform) def _scale_icon(icon: QtGui.QIcon, scale_x: float = 1.0, scale_y: float = None) -> QtGui.QIcon: """Return a new QIcon with all pixmaps scaled by scale. Parameters ---------- icon: QtGui.QIcon The icon to scale. scale: float The scale factor to apply to the icon. """ if scale_y is None: scale_y = scale_x transform = QtGui.QTransform().scale(scale_x, scale_y) return transform_icon(icon, transform) def _rotate_icon(icon: QtGui.QIcon, angle: int = 0) -> QtGui.QIcon: """Return a new QIcon with all pixmaps rotated by angle degrees. Parameters ---------- icon: QtGui.QIcon The icon to rotate. angle: int The angle in degrees to rotate the icon. """ if angle != 0: transform = QtGui.QTransform().rotate(angle) return transform_icon(icon, transform) else: return icon def _flip_icon(icon: QtGui.QIcon, flip_h: bool, flip_v: bool) -> QtGui.QIcon: """Return a new QIcon with all pixmaps mirrored. All modes (Normal/Disabled/Active/Selected) and both Off/On states are transformed, so icons that bake in a distinct Disabled-mode pixmap (e.g. MaterialIcon) keep their proper disabled appearance instead of falling back to Qt's generic auto-generated disabled look. For SVG/vector icons (e.g. MaterialIcon) ``availableSizes()`` returns an empty list; the function falls back to the two standard Material sizes (20 × 20 and 40 × 40) so the icon is still rendered at a usable resolution. """ if not flip_h and not flip_v: return icon sx = -1.0 if flip_h else 1.0 sy = -1.0 if flip_v else 1.0 return _scale_icon(icon, scale_x=sx, scale_y=sy)
[docs] def create_icon(icon_name: Union[QtGui.QIcon, str, Path], icon_color: Optional[Union[QtGui.QColor, bytes, str]] = None, icon_checked_color: Optional[Union[QtGui.QColor, bytes, str]] = None, flip_h: bool = False, flip_v: bool = False, rotate: int = 0, fill: Optional[bool] = None): """ Create an icon from various sources by order of preference: 1) icon_name is a MaterialIcon 2) icon_name is a regular QIcon 3) icon_name is a registered MaterialIcon 4) icon_name is a real path to a png 5) icon_name is a registered png in icon_library 6) icon_name is a registered ThemeIcon 7) icon_name is a registered StandardPixmap Parameters ---------- icon_name: Icon source — see priority list above. icon_color: Colour applied to the unchecked/off-state icon (MaterialIcon only). icon_checked_color: Colour applied to the checked/on-state icon (MaterialIcon only). flip_h: Mirror the icon horizontally (left ↔ right). flip_v: Mirror the icon vertically (top ↔ bottom). rotate: Rotate the icon given the value in degrees fill: Is Material Icon filled or edged. If None, left to the user configuration else force the choice on the icon rotate: rotation of the icon in degrees. If 0 nothing is done """ if isinstance(icon_name, MaterialIcon): icon_name.set_color(create_color(icon_color)) if icon_checked_color is not None: icon_name.set_color(create_color(icon_checked_color), state=QtGui.QIcon.State.On) icon = _flip_icon(icon_name, flip_h, flip_v) return _rotate_icon(icon, rotate) elif isinstance(icon_name, QtGui.QIcon): #cannot set Color on non MaterialIcons icon = _flip_icon(icon_name, flip_h, flip_v) return _rotate_icon(icon, rotate) elif resource_path_exists( MaterialIcon.resource_path( icon_name, style=MaterialIcon.Style(config('gui', 'style', 'icons', 'style')[0]), fill=config('gui', 'style', 'icons', 'fill')[0] if fill is None else fill, size=config('gui', 'style', 'icons', 'size')[0])): icon = MaterialIcon( icon_name, style=MaterialIcon.Style(config('gui', 'style', 'icons', 'style')[0]), fill=config('gui', 'style', 'icons', 'fill')[0] if fill is None else fill, size=config('gui', 'style', 'icons', 'size')[0]) icon.set_color(create_color(icon_color)) if icon_checked_color is not None: icon.set_color(create_color(icon_checked_color), state=QtGui.QIcon.State.On) elif Path(icon_name).is_file(): # Test if icon is in path icon = QtGui.QIcon() icon.addPixmap(QtGui.QPixmap(icon_name), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) elif resource_path_exists(f"icons:{icon_name}.png"): icon = QtGui.QIcon() icon.addPixmap(QtGui.QPixmap(f"icons:{icon_name}.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) elif hasattr(QtGui.QIcon,'ThemeIcon') and hasattr(QtGui.QIcon.ThemeIcon, icon_name): # Test if icon is in Qt's library icon = QtGui.QIcon.fromTheme(getattr(QtGui.QIcon.ThemeIcon, icon_name)) elif hasattr(QtWidgets.QStyle.StandardPixmap, icon_name): pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, icon_name) icon = QtWidgets.QWidget().style().standardIcon(pixmapi) else: icon = QtGui.QIcon() icon = _flip_icon(icon, flip_h, flip_v) return _rotate_icon(icon, angle=rotate)
[docs] def resource_path_exists(path: str) -> bool: return QtCore.QFile(path).exists()
[docs] @dataclasses.dataclass class Font: font_name: str = 'Tahoma' font_size: int = 14 isbold: bool = True isitalic: bool = False color: QtGui.QColor | str | tuple[int, int, int] = None
[docs] def apply_to_widget(self, widget: QtWidgets.QWidget): if hasattr(widget, 'setFont'): font = self.get_font() widget.setFont(font) if self.color is not None: color = mkColor(self.color) widget.setStyleSheet(f'color: #{hex(color.rgb())[2:]}')
[docs] def get_font(self) -> QtGui.QFont: return create_font(self.font_name, self.font_size, self.isbold, self.isitalic)
[docs] def text_color(level: int): colors = ('text', 'subtext0', 'subtext1') level = level % 3 return colors[level]
[docs] def alpha_color(level: int): return 0.5 + 0.5 * (1 - level / 5)
[docs] def color_to_rgba(color: QtGui.QColor, alpha: float = None): """ Return a rgba string to use for color argument in stylesheets""" if alpha is None: alpha = color.alpha() return f'rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})'
[docs] def button_style(level: int): return f""" QPushButton {{ background-color: {color_to_rgba(color_from_depth(get_theme().mantle, level))}; color: {color_to_rgba(get_theme().text)}; border: 1px solid {color_to_rgba(get_theme().surface1)}; border-radius: 6px; padding: 6px 24px 6px 12px; /* Plus d'espace à droite pour l'indicateur de menu */ font-weight: bold; }} QPushButton:hover {{ background-color: {color_to_rgba(get_theme().primary)}; border: 1px solid {color_to_rgba(get_theme().surface2)}; }} QPushButton:pressed {{ background-color: {color_to_rgba(get_theme().primary)}; }} QPushButton::menu-indicator {{ subcontrol-origin: padding; subcontrol-position: center right; right: 8px; width: 10px; height: 10px; }} """
[docs] def color_from_depth(color: QtGui.QColor, depth: int) -> QtGui.QColor: # Generate a distinct tint step per level # Dark themes: Stepwise lightening | Light themes: Stepwise darkening if get_theme().is_dark_theme(): # Dark theme adjustment (e.g., Nord, Monokai) level_color = color.lighter(100 + (depth * 15)) else: # Light theme adjustment (e.g., Catppuccin Latte) level_color = color.darker(100 + (depth * 10)) return level_color