Source code for pymodaq_gui.utils.styling

from pathlib import Path
from typing import 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

config = Config()
theme = qt_themes.get_theme(config('gui', 'style', 'theme')[0])

[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: Union[QtGui.QColor, str]) -> Union[QtGui.QColor, None]: if icon_color is not None: if isinstance(icon_color, str): try: icon_color = theme.__getattribute__(icon_color) except AttributeError: icon_color = QtGui.QColor(icon_color) if not icon_color.isValid(): icon_color = None return icon_color
[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: Union[QtGui.QColor, bytes, str] = None, icon_checked_color: Union[QtGui.QColor, bytes, str] = None, flip_h: bool = False, flip_v: bool = False, rotate: int = 0, fill: 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