Skip to content

Commit 28780cb

Browse files
authored
fix: Qt5Agg support darkmode icon by using svg (matplotlib#30565)
* fix: Qt5Agg support darkmode icon * fix(qt5agg): scale SVG toolbar icons to toolbar size and safely end QPainter to avoid crash * Regenerate Qt5 tool icons when device pixel ratio changes * Remove defensive check for Qt5 tool SVG icon existence * refactor(backend_qt): Use QIconEngine for on-demand icon rendering * Enhance SVG icon rendering support for multiple Qt versions * refactor(backend_qt): Improve code quality based on review feedback * Move QtSvg import to qt_compat * Implement Qt toolmanager svg icon * Simplify dark mode and large image detection * refactor(backend_qt): Improve the clarity of the code's logic. * refactor(backend_qt): simplify icon DPR handling in _IconEngine * refactor(backend_qt): enhance icon loading logic
1 parent 94def4e commit 28780cb

File tree

2 files changed

+128
-23
lines changed

2 files changed

+128
-23
lines changed

lib/matplotlib/backends/backend_qt.py

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import matplotlib.backends.qt_editor.figureoptions as figureoptions
1515
from . import qt_compat
1616
from .qt_compat import (
17-
QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted)
17+
QtCore, QtGui, QtWidgets, QtSvg, __version__, QT_API, _to_int, _isdeleted)
1818

1919

2020
# SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name
@@ -683,6 +683,120 @@ def set_window_title(self, title):
683683
self.window.setWindowTitle(title)
684684

685685

686+
class _IconEngine(QtGui.QIconEngine):
687+
"""
688+
Custom QIconEngine that automatically handles DPI scaling for tools icons.
689+
690+
This engine provides icons on-demand with proper scaling based on the current
691+
device pixel ratio, eliminating the need for manual refresh when DPI changes.
692+
"""
693+
694+
def __init__(self, image_path, toolbar=None):
695+
super().__init__()
696+
self.image_path = image_path
697+
self.toolbar = toolbar
698+
699+
def _is_dark_mode(self):
700+
return self.toolbar.palette().color(self.toolbar.backgroundRole()).value() < 128
701+
702+
def paint(self, painter, rect, mode, state):
703+
"""Paint the icon at the requested size and state."""
704+
pixmap = self.pixmap(rect.size(), mode, state)
705+
if not pixmap.isNull():
706+
painter.drawPixmap(rect, pixmap)
707+
708+
def pixmap(self, size, mode, state):
709+
"""Generate a pixmap for the requested size, mode, and state."""
710+
if size.width() <= 0 or size.height() <= 0:
711+
return QtGui.QPixmap()
712+
713+
# Try SVG first, then fall back to PNG
714+
svg_path = self.image_path.with_suffix('.svg')
715+
if svg_path.exists():
716+
pixmap = self._create_pixmap_from_svg(svg_path, size)
717+
if not pixmap.isNull():
718+
return pixmap
719+
return self._create_pixmap_from_png(self.image_path, size)
720+
721+
def _devicePixelRatio(self):
722+
"""Return the current device pixel ratio for the toolbar, defaulting to 1."""
723+
return (self.toolbar.devicePixelRatioF() or 1) if self.toolbar else 1
724+
725+
def _create_pixmap_from_svg(self, svg_path, size):
726+
"""Create a pixmap from SVG with proper scaling and dark mode support."""
727+
QSvgRenderer = getattr(QtSvg, "QSvgRenderer", None)
728+
if QSvgRenderer is None:
729+
return QtGui.QPixmap()
730+
731+
svg_content = svg_path.read_bytes()
732+
733+
if self._is_dark_mode():
734+
svg_content = svg_content.replace(b'fill:black;', b'fill:white;')
735+
svg_content = svg_content.replace(b'stroke:black;', b'stroke:white;')
736+
737+
renderer = QSvgRenderer(QtCore.QByteArray(svg_content))
738+
if not renderer.isValid():
739+
return QtGui.QPixmap()
740+
741+
dpr = self._devicePixelRatio()
742+
scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr))
743+
pixmap = QtGui.QPixmap(scaled_size)
744+
pixmap.setDevicePixelRatio(dpr)
745+
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
746+
747+
painter = QtGui.QPainter()
748+
try:
749+
painter.begin(pixmap)
750+
renderer.render(painter, QtCore.QRectF(0, 0, size.width(), size.height()))
751+
finally:
752+
if painter.isActive():
753+
painter.end()
754+
755+
return pixmap
756+
757+
def _create_pixmap_from_png(self, base_path, size):
758+
"""
759+
Create a pixmap from PNG with scaling and dark mode support.
760+
761+
Prefer to use the *_large.png with the same name; otherwise, use base_path.
762+
"""
763+
large_path = base_path.with_name(base_path.stem + '_large.png')
764+
source_pixmap = QtGui.QPixmap()
765+
for candidate in (large_path, base_path):
766+
if not candidate.exists():
767+
continue
768+
candidate_pixmap = QtGui.QPixmap(str(candidate))
769+
if not candidate_pixmap.isNull():
770+
source_pixmap = candidate_pixmap
771+
break
772+
if source_pixmap.isNull():
773+
return source_pixmap
774+
775+
dpr = self._devicePixelRatio()
776+
777+
# Scale to requested size
778+
scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr))
779+
scaled_pixmap = source_pixmap.scaled(
780+
scaled_size,
781+
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
782+
QtCore.Qt.TransformationMode.SmoothTransformation
783+
)
784+
scaled_pixmap.setDevicePixelRatio(dpr)
785+
786+
if self._is_dark_mode():
787+
# On some platforms (e.g., macOS with Qt5 in dark mode), this may
788+
# incorrectly return a black color instead of a light one.
789+
# See issue #27590 for details.
790+
icon_color = self.toolbar.palette().color(self.toolbar.foregroundRole())
791+
mask = scaled_pixmap.createMaskFromColor(
792+
QtGui.QColor('black'),
793+
QtCore.Qt.MaskMode.MaskOutColor)
794+
scaled_pixmap.fill(icon_color)
795+
scaled_pixmap.setMask(mask)
796+
797+
return scaled_pixmap
798+
799+
686800
class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
687801
toolitems = [*NavigationToolbar2.toolitems]
688802
toolitems.insert(
@@ -740,25 +854,16 @@ def _icon(self, name):
740854
"""
741855
Construct a `.QIcon` from an image file *name*, including the extension
742856
and relative to Matplotlib's "images" data directory.
857+
858+
Uses _IconEngine for automatic DPI scaling.
743859
"""
744-
# use a high-resolution icon with suffix '_large' if available
745-
# note: user-provided icons may not have '_large' versions
860+
# Get the image path
746861
path_regular = cbook._get_data_path('images', name)
747-
path_large = path_regular.with_name(
748-
path_regular.name.replace('.png', '_large.png'))
749-
filename = str(path_large if path_large.exists() else path_regular)
750-
751-
pm = QtGui.QPixmap(filename)
752-
pm.setDevicePixelRatio(
753-
self.devicePixelRatioF() or 1) # rarely, devicePixelRatioF=0
754-
if self.palette().color(self.backgroundRole()).value() < 128:
755-
icon_color = self.palette().color(self.foregroundRole())
756-
mask = pm.createMaskFromColor(
757-
QtGui.QColor('black'),
758-
QtCore.Qt.MaskMode.MaskOutColor)
759-
pm.fill(icon_color)
760-
pm.setMask(mask)
761-
return QtGui.QIcon(pm)
862+
863+
# Create icon using our custom engine for automatic DPI handling
864+
engine = _IconEngine(path_regular, self)
865+
return QtGui.QIcon(engine)
866+
762867

763868
def edit_parameters(self):
764869
axes = self.canvas.figure.get_axes()

lib/matplotlib/backends/qt_compat.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,27 +64,27 @@
6464

6565

6666
def _setup_pyqt5plus():
67-
global QtCore, QtGui, QtWidgets, __version__
67+
global QtCore, QtGui, QtWidgets, QtSvg, __version__
6868
global _isdeleted, _to_int
6969

7070
if QT_API == QT_API_PYQT6:
71-
from PyQt6 import QtCore, QtGui, QtWidgets, sip
71+
from PyQt6 import QtCore, QtGui, QtWidgets, sip, QtSvg
7272
__version__ = QtCore.PYQT_VERSION_STR
7373
QtCore.Signal = QtCore.pyqtSignal
7474
QtCore.Slot = QtCore.pyqtSlot
7575
QtCore.Property = QtCore.pyqtProperty
7676
_isdeleted = sip.isdeleted
7777
_to_int = operator.attrgetter('value')
7878
elif QT_API == QT_API_PYSIDE6:
79-
from PySide6 import QtCore, QtGui, QtWidgets, __version__
79+
from PySide6 import QtCore, QtGui, QtWidgets, QtSvg, __version__
8080
import shiboken6
8181
def _isdeleted(obj): return not shiboken6.isValid(obj)
8282
if parse_version(__version__) >= parse_version('6.4'):
8383
_to_int = operator.attrgetter('value')
8484
else:
8585
_to_int = int
8686
elif QT_API == QT_API_PYQT5:
87-
from PyQt5 import QtCore, QtGui, QtWidgets
87+
from PyQt5 import QtCore, QtGui, QtWidgets, QtSvg
8888
import sip
8989
__version__ = QtCore.PYQT_VERSION_STR
9090
QtCore.Signal = QtCore.pyqtSignal
@@ -93,7 +93,7 @@ def _isdeleted(obj): return not shiboken6.isValid(obj)
9393
_isdeleted = sip.isdeleted
9494
_to_int = int
9595
elif QT_API == QT_API_PYSIDE2:
96-
from PySide2 import QtCore, QtGui, QtWidgets, __version__
96+
from PySide2 import QtCore, QtGui, QtWidgets, QtSvg, __version__
9797
try:
9898
from PySide2 import shiboken2
9999
except ImportError:

0 commit comments

Comments
 (0)