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