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]
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 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 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