|
14 | 14 | import matplotlib.backends.qt_editor.figureoptions as figureoptions |
15 | 15 | from . import qt_compat |
16 | 16 | 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) |
18 | 18 |
|
19 | 19 |
|
20 | 20 | # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name |
@@ -683,6 +683,120 @@ def set_window_title(self, title): |
683 | 683 | self.window.setWindowTitle(title) |
684 | 684 |
|
685 | 685 |
|
| 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 | + |
686 | 800 | class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): |
687 | 801 | toolitems = [*NavigationToolbar2.toolitems] |
688 | 802 | toolitems.insert( |
@@ -740,25 +854,16 @@ def _icon(self, name): |
740 | 854 | """ |
741 | 855 | Construct a `.QIcon` from an image file *name*, including the extension |
742 | 856 | and relative to Matplotlib's "images" data directory. |
| 857 | +
|
| 858 | + Uses _IconEngine for automatic DPI scaling. |
743 | 859 | """ |
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 |
746 | 861 | 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 | + |
762 | 867 |
|
763 | 868 | def edit_parameters(self): |
764 | 869 | axes = self.canvas.figure.get_axes() |
|
0 commit comments