diff --git a/docs/fonticon.md b/docs/fonticon.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/fonticon1.py b/examples/fonticon1.py new file mode 100644 index 0000000..9971f73 --- /dev/null +++ b/examples/fonticon1.py @@ -0,0 +1,20 @@ +try: + from fonticon_fa5 import FA5S +except ImportError as e: + raise type(e)( + "This example requires the fontawesome fontpack:\n\n" + "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" + ) + +from superqt.fonticon import icon, pulse +from superqt.qtcompat.QtCore import QSize +from superqt.qtcompat.QtWidgets import QApplication, QPushButton + +app = QApplication([]) + +btn2 = QPushButton() +btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2))) +btn2.setIconSize(QSize(225, 225)) +btn2.show() + +app.exec() diff --git a/examples/fonticon2.py b/examples/fonticon2.py new file mode 100644 index 0000000..f3a49e6 --- /dev/null +++ b/examples/fonticon2.py @@ -0,0 +1,20 @@ +try: + from fonticon_fa5 import FA5S +except ImportError as e: + raise type(e)( + "This example requires the fontawesome fontpack:\n\n" + "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" + ) + +from superqt.fonticon import setTextIcon +from superqt.qtcompat.QtWidgets import QApplication, QPushButton + +app = QApplication([]) + + +btn4 = QPushButton() +btn4.resize(275, 275) +setTextIcon(btn4, FA5S.hamburger) +btn4.show() + +app.exec() diff --git a/examples/fonticon3.py b/examples/fonticon3.py new file mode 100644 index 0000000..6a90467 --- /dev/null +++ b/examples/fonticon3.py @@ -0,0 +1,40 @@ +try: + from fonticon_fa5 import FA5S +except ImportError as e: + raise type(e)( + "This example requires the fontawesome fontpack:\n\n" + "pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git" + ) + +from superqt.fonticon import IconOpts, icon, pulse, spin +from superqt.qtcompat.QtCore import QSize +from superqt.qtcompat.QtWidgets import QApplication, QPushButton + +app = QApplication([]) + +btn = QPushButton() +btn.setIcon( + icon( + FA5S.smile, + color="blue", + states={ + "active": IconOpts( + glyph_key=FA5S.spinner, + color="red", + scale_factor=0.5, + animation=pulse(btn), + ), + "disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)}, + }, + ) +) +btn.setIconSize(QSize(256, 256)) +btn.show() + + +@btn.clicked.connect +def toggle_state(): + btn.setChecked(not btn.isChecked()) + + +app.exec() diff --git a/examples/icon_explorer.py b/examples/icon_explorer.py new file mode 100644 index 0000000..46ca5ce --- /dev/null +++ b/examples/icon_explorer.py @@ -0,0 +1,377 @@ +from superqt.fonticon._plugins import loaded +from superqt.qtcompat import QtCore, QtGui, QtWidgets +from superqt.qtcompat.QtCore import Qt + +P = loaded(load_all=True) +if not P: + print("you have no font packs loaded!") + + +class GlyphDelegate(QtWidgets.QItemDelegate): + def createEditor(self, parent, option, index): + if index.column() < 2: + edit = QtWidgets.QLineEdit(parent) + edit.editingFinished.connect(self.emitCommitData) + return edit + comboBox = QtWidgets.QComboBox(parent) + if index.column() == 2: + comboBox.addItem("Normal") + comboBox.addItem("Active") + comboBox.addItem("Disabled") + comboBox.addItem("Selected") + elif index.column() == 3: + comboBox.addItem("Off") + comboBox.addItem("On") + + comboBox.activated.connect(self.emitCommitData) + return comboBox + + def setEditorData(self, editor, index): + if index.column() < 2: + editor.setText(index.model().data(index)) + return + comboBox = editor + if comboBox: + pos = comboBox.findText( + index.model().data(index), Qt.MatchFlag.MatchExactly + ) + comboBox.setCurrentIndex(pos) + + def setModelData(self, editor, model, index): + if editor: + text = editor.text() if index.column() < 2 else editor.currentText() + model.setData(index, text) + + def emitCommitData(self): + self.commitData.emit(self.sender()) + + +class IconPreviewArea(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + mainLayout = QtWidgets.QGridLayout() + self.setLayout(mainLayout) + + self.icon = QtGui.QIcon() + self.size = QtCore.QSize() + self.stateLabels = [] + self.modeLabels = [] + self.pixmapLabels = [] + + self.stateLabels.append(self.createHeaderLabel("Off")) + self.stateLabels.append(self.createHeaderLabel("On")) + self.modeLabels.append(self.createHeaderLabel("Normal")) + self.modeLabels.append(self.createHeaderLabel("Active")) + self.modeLabels.append(self.createHeaderLabel("Disabled")) + self.modeLabels.append(self.createHeaderLabel("Selected")) + + for j, label in enumerate(self.stateLabels): + mainLayout.addWidget(label, j + 1, 0) + + for i, label in enumerate(self.modeLabels): + mainLayout.addWidget(label, 0, i + 1) + + self.pixmapLabels.append([]) + for j in range(len(self.stateLabels)): + self.pixmapLabels[i].append(self.createPixmapLabel()) + mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1) + + def setIcon(self, icon): + self.icon = icon + self.updatePixmapLabels() + + def setSize(self, size): + if size != self.size: + self.size = size + self.updatePixmapLabels() + + def createHeaderLabel(self, text): + label = QtWidgets.QLabel("%s" % text) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + return label + + def createPixmapLabel(self): + label = QtWidgets.QLabel() + label.setEnabled(False) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setFrameShape(QtWidgets.QFrame.Box) + label.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + label.setBackgroundRole(QtGui.QPalette.Base) + label.setAutoFillBackground(True) + label.setMinimumSize(132, 132) + return label + + def updatePixmapLabels(self): + for i in range(len(self.modeLabels)): + if i == 0: + mode = QtGui.QIcon.Mode.Normal + elif i == 1: + mode = QtGui.QIcon.Mode.Active + elif i == 2: + mode = QtGui.QIcon.Mode.Disabled + else: + mode = QtGui.QIcon.Mode.Selected + + for j in range(len(self.stateLabels)): + state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[ + j == 0 + ] + pixmap = self.icon.pixmap(self.size, mode, state) + self.pixmapLabels[i][j].setPixmap(pixmap) + self.pixmapLabels[i][j].setEnabled(not pixmap.isNull()) + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self, parent=None): + super().__init__(parent) + + self.centralWidget = QtWidgets.QWidget() + self.setCentralWidget(self.centralWidget) + + self.createPreviewGroupBox() + self.createGlyphBox() + self.createIconSizeGroupBox() + + mainLayout = QtWidgets.QGridLayout() + mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2) + mainLayout.addWidget(self.glyphGroupBox, 1, 0) + mainLayout.addWidget(self.iconSizeGroupBox, 1, 1) + self.centralWidget.setLayout(mainLayout) + + self.setWindowTitle("Icons") + self.otherRadioButton.click() + + self.resize(self.minimumSizeHint()) + + def changeSize(self): + if self.otherRadioButton.isChecked(): + extent = self.otherSpinBox.value() + else: + if self.smallRadioButton.isChecked(): + metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize + elif self.largeRadioButton.isChecked(): + metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize + elif self.toolBarRadioButton.isChecked(): + metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize + elif self.listViewRadioButton.isChecked(): + metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize + elif self.iconViewRadioButton.isChecked(): + metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize + else: + metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize + + extent = QtWidgets.QApplication.style().pixelMetric(metric) + + self.previewArea.setSize(QtCore.QSize(extent, extent)) + self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked()) + + def changeIcon(self): + from superqt import fonticon + + icon = None + for row in range(self.glyphTable.rowCount()): + item0 = self.glyphTable.item(row, 0) + item1 = self.glyphTable.item(row, 1) + item2 = self.glyphTable.item(row, 2) + item3 = self.glyphTable.item(row, 3) + + if item0.checkState() != Qt.CheckState.Checked: + continue + key = item0.text() + if not key: + continue + + if item2.text() == "Normal": + mode = QtGui.QIcon.Mode.Normal + elif item2.text() == "Active": + mode = QtGui.QIcon.Mode.Active + elif item2.text() == "Disabled": + mode = QtGui.QIcon.Mode.Disabled + else: + mode = QtGui.QIcon.Mode.Selected + + color = item1.text() or None + state = ( + QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off + ) + try: + if icon is None: + icon = fonticon.icon(key, color=color) + else: + icon.addState(state, mode, glyph_key=key, color=color) + except Exception as e: + print(e) + continue + if icon: + self.previewArea.setIcon(icon) + + def createPreviewGroupBox(self): + self.previewGroupBox = QtWidgets.QGroupBox("Preview") + + self.previewArea = IconPreviewArea() + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.previewArea) + self.previewGroupBox.setLayout(layout) + + def createGlyphBox(self): + self.glyphGroupBox = QtWidgets.QGroupBox("Glpyhs") + self.glyphGroupBox.setMinimumSize(480, 200) + self.glyphTable = QtWidgets.QTableWidget() + self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.glyphTable.setItemDelegate(GlyphDelegate(self)) + + self.glyphTable.horizontalHeader().setDefaultSectionSize(100) + self.glyphTable.setColumnCount(4) + self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State")) + self.glyphTable.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.Stretch + ) + self.glyphTable.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.Fixed + ) + self.glyphTable.horizontalHeader().setSectionResizeMode( + 2, QtWidgets.QHeaderView.Fixed + ) + self.glyphTable.horizontalHeader().setSectionResizeMode( + 3, QtWidgets.QHeaderView.Fixed + ) + self.glyphTable.verticalHeader().hide() + + self.glyphTable.itemChanged.connect(self.changeIcon) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.glyphTable) + self.glyphGroupBox.setLayout(layout) + self.changeIcon() + + p0 = list(P)[-1] + key = f"{p0}.{list(P[p0])[1]}" + for _ in range(4): + row = self.glyphTable.rowCount() + self.glyphTable.setRowCount(row + 1) + + item0 = QtWidgets.QTableWidgetItem() + item1 = QtWidgets.QTableWidgetItem() + + if _ == 0: + item0.setText(key) + # item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable) + + item2 = QtWidgets.QTableWidgetItem("Normal") + item3 = QtWidgets.QTableWidgetItem("Off") + + self.glyphTable.setItem(row, 0, item0) + self.glyphTable.setItem(row, 1, item1) + self.glyphTable.setItem(row, 2, item2) + self.glyphTable.setItem(row, 3, item3) + self.glyphTable.openPersistentEditor(item2) + self.glyphTable.openPersistentEditor(item3) + + item0.setCheckState(Qt.CheckState.Checked) + + def createIconSizeGroupBox(self): + self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size") + + self.smallRadioButton = QtWidgets.QRadioButton() + self.largeRadioButton = QtWidgets.QRadioButton() + self.toolBarRadioButton = QtWidgets.QRadioButton() + self.listViewRadioButton = QtWidgets.QRadioButton() + self.iconViewRadioButton = QtWidgets.QRadioButton() + self.tabBarRadioButton = QtWidgets.QRadioButton() + self.otherRadioButton = QtWidgets.QRadioButton("Other:") + + self.otherSpinBox = QtWidgets.QSpinBox() + self.otherSpinBox.setRange(8, 128) + self.otherSpinBox.setValue(64) + + self.smallRadioButton.toggled.connect(self.changeSize) + self.largeRadioButton.toggled.connect(self.changeSize) + self.toolBarRadioButton.toggled.connect(self.changeSize) + self.listViewRadioButton.toggled.connect(self.changeSize) + self.iconViewRadioButton.toggled.connect(self.changeSize) + self.tabBarRadioButton.toggled.connect(self.changeSize) + self.otherRadioButton.toggled.connect(self.changeSize) + self.otherSpinBox.valueChanged.connect(self.changeSize) + + otherSizeLayout = QtWidgets.QHBoxLayout() + otherSizeLayout.addWidget(self.otherRadioButton) + otherSizeLayout.addWidget(self.otherSpinBox) + otherSizeLayout.addStretch() + + layout = QtWidgets.QGridLayout() + layout.addWidget(self.smallRadioButton, 0, 0) + layout.addWidget(self.largeRadioButton, 1, 0) + layout.addWidget(self.toolBarRadioButton, 2, 0) + layout.addWidget(self.listViewRadioButton, 0, 1) + layout.addWidget(self.iconViewRadioButton, 1, 1) + layout.addWidget(self.tabBarRadioButton, 2, 1) + layout.addLayout(otherSizeLayout, 3, 0, 1, 2) + layout.setRowStretch(4, 1) + self.iconSizeGroupBox.setLayout(layout) + self.changeStyle() + + def changeStyle(self, style=None): + style = style or QtWidgets.QApplication.style().objectName() + style = QtWidgets.QStyleFactory.create(style) + if not style: + return + + QtWidgets.QApplication.setStyle(style) + + self.setButtonText( + self.smallRadioButton, + "Small (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_SmallIconSize, + ) + self.setButtonText( + self.largeRadioButton, + "Large (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_LargeIconSize, + ) + self.setButtonText( + self.toolBarRadioButton, + "Toolbars (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize, + ) + self.setButtonText( + self.listViewRadioButton, + "List views (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize, + ) + self.setButtonText( + self.iconViewRadioButton, + "Icon views (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize, + ) + self.setButtonText( + self.tabBarRadioButton, + "Tab bars (%d x %d)", + style, + QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize, + ) + + self.changeSize() + + @staticmethod + def setButtonText(button, label, style, metric): + metric_value = style.pixelMetric(metric) + button.setText(label % (metric_value, metric_value)) + + +if __name__ == "__main__": + + import sys + + app = QtWidgets.QApplication(sys.argv) + mainWin = MainWindow() + mainWin.show() + sys.exit(app.exec_()) diff --git a/setup.cfg b/setup.cfg index b176172..f06e20b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -59,6 +59,10 @@ dev = pytest-qt tox tox-conda +font_fa5 = + fonticon-fontawesome5 +font_mi5 = + fonticon-materialdesignicons5 pyqt5 = pyqt5 pyqt6 = @@ -96,4 +100,7 @@ ignore = D100 profile = black [tool:pytest] -addopts = -W error +filterwarnings = + error + ignore:QPixmapCache.find:DeprecationWarning: + ignore:SelectableGroups dict interface:DeprecationWarning diff --git a/src/superqt/fonticon/__init__.py b/src/superqt/fonticon/__init__.py new file mode 100644 index 0000000..871bf55 --- /dev/null +++ b/src/superqt/fonticon/__init__.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +__all__ = [ + "addFont", + "ENTRY_POINT", + "font", + "icon", + "IconFont", + "IconFontMeta", + "IconOpts", + "Animation", + "pulse", + "spin", +] + +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + +from ._animations import Animation, pulse, spin +from ._iconfont import IconFont, IconFontMeta +from ._plugins import FontIconManager as _FIM +from ._qfont_icon import DEFAULT_SCALING_FACTOR, IconOptionDict, IconOpts +from ._qfont_icon import QFontIconStore as _QFIS + +if TYPE_CHECKING: + from superqt.qtcompat.QtGui import QFont, QTransform + from superqt.qtcompat.QtWidgets import QWidget + + from ._qfont_icon import QFontIcon, ValidColor + +ENTRY_POINT = _FIM.ENTRY_POINT + + +# FIXME: currently, an Animation requires a *pre-bound* QObject. which makes it very +# awkward to use animations when declaratively listing icons. It would be much better +# to have a way to find the widget later, to execute the animation... short of that, I +# think we should take animation off of `icon` here, and suggest that it be an +# an additional convenience method after the icon has been bound to a QObject. +def icon( + glyph_key: str, + scale_factor: float = DEFAULT_SCALING_FACTOR, + color: ValidColor = None, + opacity: float = 1, + animation: Optional[Animation] = None, + transform: Optional[QTransform] = None, + states: Dict[str, Union[IconOptionDict, IconOpts]] = {}, +) -> QFontIcon: + """Create a QIcon for `glyph_key`, with a number of optional settings + + The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glpyh. + In most cases, the key should be provided by a plugin in the environment, like: + + https://github.com/tlambert03/fonticon-fontawesome5 ('fa5s' & 'fa5r' prefixes) + https://github.com/tlambert03/fonticon-materialdesignicons6 ('mdi6' prefix) + + ...but fonts can also be added manually using :func:`addFont`. + + Parameters + ---------- + glyph_key : str + String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'. + scale_factor : float, optional + Scale factor (fraction of widget height), When widget icon is painted on widget, + it will use `font.setPixelSize(round(wdg.height() * scale_factor))`. + by default 0.875. + color : ValidColor, optional + Color for the font, by default None. (e.g. The default `QColor`) + Valid color types include `QColor`, `int`, `str`, `Qt.GlobalColor`, `tuple` (of + integer: RGB[A]) (anything that can be passed to `QColor`). + opacity : float, optional + Opacity of icon, by default 1 + animation : Animation, optional + Animation for the icon. A subclass of superqt.fonticon.Animation, that provides + a concrete `animate` method. (see "spin" and "pulse" for examples). + by default None. + transform : QTransform, optional + A `QTransform` to apply when painting the icon, by default None + states : dict, optional + Provide additional styling for the icon in different states. `states` must be + a mapping of string to dict, where: + + - the key represents a `QIcon.State` ("on", "off"), a `QIcon.Mode` ("normal", + "active", "selected", "disabled"), or any combination of a state & mode + separated by an underscore (e.g. "off_active", "selected_on", etc...). + - the value is a dict with all of the same key/value meanings listed above as + parameters to this function (e.g. `glyph_key`, `color`,`scale_factor`, + `animation`, etc...) + + Missing keys in the state dicts will be taken from the default options, provided + by the paramters above. + + Returns + ------- + QFontIcon + A subclass of QIcon. Can be used wherever QIcons are used, such as + `widget.setIcon()` + + Examples + -------- + # simple example (assumes the font-awesome5 plugin is installed) + >>> btn = QPushButton() + >>> btn.setIcon(icon('fa5s.smile')) + + # can also directly import from fonticon_fa5 + >>> from fonticon_fa5 import FA5S + >>> btn.setIcon(icon(FA5S.smile)) + + # with animation + >>> btn2 = QPushButton() + >>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2))) + + # complicated example + >>> btn = QPushButton() + >>> btn.setIcon( + ... icon( + ... FA5S.ambulance, + ... color="blue", + ... states={ + ... "active": { + ... "glyph": FA5S.bath, + ... "color": "red", + ... "scale_factor": 0.5, + ... "animation": pulse(btn), + ... }, + ... "disabled": { + ... "color": "green", + ... "scale_factor": 0.8, + ... "animation": spin(btn) + ... }, + ... }, + ... ) + ... ) + >>> btn.setIconSize(QSize(256, 256)) + >>> btn.show() + + """ + return _QFIS.instance().icon( + glyph_key, + scale_factor=scale_factor, + color=color, + opacity=opacity, + animation=animation, + transform=transform, + states=states, + ) + + +def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -> None: + """Set text on a widget to a specific font & glyph. + + This is an alternative to setting a QIcon with a pixmap. It may be easier to + combine with dynamic stylesheets. + + Parameters + ---------- + wdg : QWidget + A widget supporting a `setText` method. + glyph_key : str + String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'. + size : int, optional + Size for QFont. passed to `setPixelSize`, by default None + """ + return _QFIS.instance().setTextIcon(widget, glyph_key, size) + + +def font(font_prefix: str, size: Optional[int] = None) -> QFont: + """Create QFont for `font_prefix` + + Parameters + ---------- + font_prefix : str + Font_prefix, such as 'fa5s' or 'mdi6', representing a font-family and style. + size : int, optional + Size for QFont. passed to `setPixelSize`, by default None + + Returns + ------- + QFont + QFont instance that can be used to add fonticons to widgets. + """ + return _QFIS.instance().font(font_prefix, size) + + +def addFont( + filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None +) -> Optional[Tuple[str, str]]: + """Add OTF/TTF file at `filepath` to the registry under `prefix`. + + If you'd like to later use a fontkey in the form of `prefix.some-name`, then + `charmap` must be provided and provide a mapping for all of the glyph names + to their unicode numbers. If a charmap is not provided, glyphs must be directly + accessed with their unicode as something like `key.\uffff`. + + NOTE: in most cases, users will not need this. + Instead, they should install a font plugin, like: + https://github.com/tlambert03/fonticon-fontawesome5 + https://github.com/tlambert03/fonticon-materialdesignicons6 + + Parameters + ---------- + filepath : str + Path to an OTF or TTF file containing the fonts + prefix : str + A prefix that will represent this font file when used for lookup. For example, + 'fa5s' for 'Font-Awesome 5 Solid'. + charmap : Dict[str, str], optional + optional mapping for all of the glyph names to their unicode numbers. + See note above. + + Returns + ------- + Tuple[str, str], optional + font-family and font-style for the file just registered, or `None` if + something goes wrong. + """ + return _QFIS.instance().addFont(filepath, prefix, charmap) + + +del DEFAULT_SCALING_FACTOR diff --git a/src/superqt/fonticon/_animations.py b/src/superqt/fonticon/_animations.py new file mode 100644 index 0000000..85ada01 --- /dev/null +++ b/src/superqt/fonticon/_animations.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod + +from superqt.qtcompat.QtCore import QRectF, QTimer +from superqt.qtcompat.QtGui import QPainter +from superqt.qtcompat.QtWidgets import QWidget + + +class Animation(ABC): + def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1): + self.parent_widget = parent_widget + self.timer = QTimer() + self.timer.timeout.connect(self._update) # type: ignore + self.timer.setInterval(interval) + self._angle = 0 + self._step = step + + def _update(self): + if self.timer.isActive(): + self._angle += self._step + self.parent_widget.update() + + @abstractmethod + def animate(self, painter: QPainter): + """Setup and start the timer for the animation.""" + + +class spin(Animation): + def animate(self, painter: QPainter): + if not self.timer.isActive(): + self.timer.start() + + mid = QRectF(painter.viewport()).center() + painter.translate(mid) + painter.rotate(self._angle % 360) + painter.translate(-mid) + + +class pulse(spin): + def __init__(self, parent_widget: QWidget = None): + super().__init__(parent_widget, interval=200, step=45) diff --git a/src/superqt/fonticon/_iconfont.py b/src/superqt/fonticon/_iconfont.py new file mode 100644 index 0000000..e486c41 --- /dev/null +++ b/src/superqt/fonticon/_iconfont.py @@ -0,0 +1,88 @@ +from typing import Mapping, Type, Union + +FONTFILE_ATTR = "__font_file__" + + +class IconFontMeta(type): + """IconFont metaclass. + + This updates the value of all class attributes to be prefaced with the class + name (lowercase), and makes sure that all values are valid characters. + + Examples + -------- + This metaclass turns the following class: + + class FA5S(metaclass=IconFontMeta): + __font_file__ = 'path/to/font.otf' + some_char = 0xfa42 + + into this: + + class FA5S: + __font_file__ = path/to/font.otf' + some_char = 'fa5s.\ufa42' + + In usage, this means that someone could use `icon(FA5S.some_char)` (provided + that the FA5S class/namespace has already been registered). This makes + IDE attribute checking and autocompletion easier. + """ + + __font_file__: str + + def __new__(cls, name, bases, namespace, **kwargs): + # make sure this class provides the __font_file__ interface + ff = namespace.get(FONTFILE_ATTR) + if not (ff and isinstance(ff, (str, classmethod))): + raise TypeError( + f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod" + ) + + # update all values to be `key.unicode` + prefix = name.lower() + for k, v in list(namespace.items()): + if k.startswith("__"): + continue + char = chr(v) if isinstance(v, int) else v + if len(char) != 1: + raise TypeError( + "Invalid Font: All fonts values must be a single " + f"unicode char. ('{name}.{char}' has length {len(char)}). " + "You may use unicode representations: like '\\uf641' or '0xf641'" + ) + namespace[k] = f"{prefix}.{char}" + + return super().__new__(cls, name, bases, namespace, **kwargs) + + +class IconFont(metaclass=IconFontMeta): + """Helper class that provides a standard way to create an IconFont. + + Examples + -------- + + class FA5S(IconFont): + __font_file__ = '...' + some_char = 0xfa42 + """ + + __slots__ = () + __font_file__ = "..." + + +def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]: + """Convenience to convert a namespace (class, module, dict) into an IconFont.""" + if isinstance(namespace, type): + assert isinstance( + getattr(namespace, FONTFILE_ATTR), str + ), "Not a valid font type" + return namespace # type: ignore + elif hasattr(namespace, "__dict__"): + ns = dict(namespace.__dict__) + else: + raise ValueError( + "namespace must be a mapping or an object with __dict__ attribute." + ) + if not str.isidentifier(name): + raise ValueError(f"name {name!r} is not a valid identifier.") + return type(name, (IconFont,), ns) diff --git a/src/superqt/fonticon/_plugins.py b/src/superqt/fonticon/_plugins.py new file mode 100644 index 0000000..3b1d6e0 --- /dev/null +++ b/src/superqt/fonticon/_plugins.py @@ -0,0 +1,103 @@ +from typing import Dict, List, Set, Tuple + +from ._iconfont import IconFontMeta, namespace2font + +try: + from importlib.metadata import EntryPoint, entry_points +except ImportError: + from importlib_metadata import EntryPoint, entry_points # type: ignore + + +class FontIconManager: + + ENTRY_POINT = "superqt.fonticon" + _PLUGINS: Dict[str, EntryPoint] = {} + _LOADED: Dict[str, IconFontMeta] = {} + _BLOCKED: Set[EntryPoint] = set() + + def _discover_fonts(self) -> None: + self._PLUGINS.clear() + for ep in entry_points().get(self.ENTRY_POINT, {}): + if ep not in self._BLOCKED: + self._PLUGINS[ep.name] = ep + + def _get_font_class(self, key: str) -> IconFontMeta: + """Get IconFont given a key. + + Parameters + ---------- + key : str + font key to load. + + Returns + ------- + IconFontMeta + Instance of IconFontMeta + + Raises + ------ + KeyError + If no plugin provides this key + ImportError + If a plugin provides the key, but the entry point doesn't load + TypeError + If the entry point loads, but is not an IconFontMeta + """ + if key not in self._LOADED: + # get the entrypoint + if key not in self._PLUGINS: + self._discover_fonts() + ep = self._PLUGINS.get(key) + if ep is None: + raise KeyError(f"No plugin provides the key {key!r}") + + # load the entry point + try: + font = ep.load() + except Exception as e: + self._PLUGINS.pop(key) + self._BLOCKED.add(ep) + raise ImportError(f"Failed to load {ep.value}. Plugin blocked") from e + + # make sure it's a proper IconFont + try: + self._LOADED[key] = namespace2font(font, ep.name.upper()) + except Exception as e: + self._PLUGINS.pop(key) + self._BLOCKED.add(ep) + raise TypeError( + f"Failed to create fonticon from {ep.value}: {e}" + ) from e + return self._LOADED[key] + + def dict(self) -> dict: + return { + key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) + for key, cls in self._LOADED.items() + } + + +_manager = FontIconManager() +get_font_class = _manager._get_font_class + + +def discover() -> Tuple[str]: + _manager._discover_fonts() + + +def available() -> Tuple[str]: + return tuple(_manager._PLUGINS) + + +def loaded(load_all=False) -> Dict[str, List[str]]: + if load_all: + discover() + for x in available(): + try: + _manager._get_font_class(x) + except Exception: + continue + return { + key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__)) + for key, cls in _manager._LOADED.items() + } diff --git a/src/superqt/fonticon/_qfont_icon.py b/src/superqt/fonticon/_qfont_icon.py new file mode 100644 index 0000000..ef87bd0 --- /dev/null +++ b/src/superqt/fonticon/_qfont_icon.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +import warnings +from collections import abc +from dataclasses import dataclass +from pathlib import Path +from typing import DefaultDict, Dict, Optional, Sequence, Tuple, Type, Union, cast + +from typing_extensions import TypedDict + +from ..qtcompat import QT_VERSION +from ..qtcompat.QtCore import QObject, QPoint, QRect, QSize, Qt +from ..qtcompat.QtGui import ( + QColor, + QFont, + QFontDatabase, + QGuiApplication, + QIcon, + QIconEngine, + QPainter, + QPixmap, + QPixmapCache, + QTransform, +) +from ..qtcompat.QtWidgets import QApplication, QStyleOption, QWidget +from ..utils import QMessageHandler +from ._animations import Animation + + +class Unset: + def __repr__(self) -> str: + return "UNSET" + + +_Unset = Unset() + +# A 16 pixel-high icon yields a font size of 14, which is pixel perfect +# for font-awesome. 16 * 0.875 = 14 +# The reason why the glyph size is smaller than the icon size is to +# account for font bearing. +DEFAULT_SCALING_FACTOR = 0.875 +DEFAULT_OPACITY = 1 +ValidColor = Union[ + QColor, + int, + str, + Qt.GlobalColor, + Tuple[int, int, int, int], + Tuple[int, int, int], + None, +] + +StateOrMode = Union[QIcon.State, QIcon.Mode] +StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]] +_SM_MAP: Dict[str, StateOrMode] = { + "on": QIcon.State.On, + "off": QIcon.State.Off, + "normal": QIcon.Mode.Normal, + "active": QIcon.Mode.Active, + "selected": QIcon.Mode.Selected, + "disabled": QIcon.Mode.Disabled, +} + + +def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]: + """return state/mode tuple given a variety of valid inputs. + + Input can be either a string, or a sequence of state or mode enums. + Strings can be any combination of on, off, normal, active, selected, disabled, + sep by underscore. + """ + _sm: Sequence[StateOrMode] + if isinstance(key, str): + try: + _sm = [_SM_MAP[k.lower()] for k in key.split("_")] + except KeyError: + raise ValueError( + f"{key!r} is not a valid state key, must be a combination of {{on, " + "off, active, disabled, selected, normal} separated by underscore" + ) + else: + _sm = key if isinstance(key, abc.Sequence) else [key] # type: ignore + + state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off) + mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal) + return state, mode + + +class IconOptionDict(TypedDict, total=False): + glyph_key: str + scale_factor: float + color: ValidColor + opacity: float + animation: Optional[Animation] + transform: Optional[QTransform] + + +# public facing, for a nicer IDE experience than a dict +# The difference between IconOpts and _IconOptions is that all of IconOpts +# all default to `_Unset` and are intended to extend some base/default option +# IconOpts are *not* guaranteed to be fully capable of rendering an icon, whereas +# IconOptions are. +@dataclass +class IconOpts: + glyph_key: Union[str, Unset] = _Unset + scale_factor: Union[float, Unset] = _Unset + color: Union[ValidColor, Unset] = _Unset + opacity: Union[float, Unset] = _Unset + animation: Union[Animation, Unset, None] = _Unset + transform: Union[QTransform, Unset, None] = _Unset + + def dict(self) -> IconOptionDict: + # not using asdict due to pickle errors on animation + d = {k: v for k, v in vars(self).items() if v is not _Unset} + return cast(IconOptionDict, d) + + +@dataclass +class _IconOptions: + """The set of options needed to render a font in a single State/Mode.""" + + glyph_key: str + scale_factor: float = DEFAULT_SCALING_FACTOR + color: ValidColor = None + opacity: float = DEFAULT_OPACITY + animation: Optional[Animation] = None + transform: Optional[QTransform] = None + + def _update(self, icon_opts: IconOpts) -> _IconOptions: + return _IconOptions(**{**vars(self), **icon_opts.dict()}) + + def dict(self) -> IconOptionDict: + # not using asdict due to pickle errors on animation + return cast(IconOptionDict, vars(self)) + + +class _QFontIconEngine(QIconEngine): + _opt_hash: str = "" + + def __init__(self, options: _IconOptions): + super().__init__() + self._opts: DefaultDict[ + QIcon.State, Dict[QIcon.Mode, Optional[_IconOptions]] + ] = DefaultDict(dict) + self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options + self.update_hash() + + @property + def _default_opts(self) -> _IconOptions: + return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal]) + + def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None: + self._opts[state][mode] = self._default_opts._update(opts) + self.update_hash() + + def clone(self) -> QIconEngine: # pragma: no cover + ico = _QFontIconEngine(self._default_opts) + ico._opts = self._opts.copy() + return ico + + def _get_opts(self, state: QIcon.State, mode: QIcon.Mode) -> _IconOptions: + opts = self._opts[state].get(mode) + if opts: + return opts + + opp_state = QIcon.State.Off if state == QIcon.State.On else QIcon.State.On + if mode in (QIcon.Mode.Disabled, QIcon.Mode.Selected): + opp_mode = ( + QIcon.Mode.Disabled + if mode == QIcon.Mode.Selected + else QIcon.Mode.Selected + ) + for m, s in [ + (QIcon.Mode.Normal, state), + (QIcon.Mode.Active, state), + (mode, opp_state), + (QIcon.Mode.Normal, opp_state), + (QIcon.Mode.Active, opp_state), + (opp_mode, state), + (opp_mode, opp_state), + ]: + opts = self._opts[s].get(m) + if opts: + return opts + else: + opp_mode = ( + QIcon.Mode.Active if mode == QIcon.Mode.Normal else QIcon.Mode.Normal + ) + for m, s in [ + (opp_mode, state), + (mode, opp_state), + (opp_mode, opp_state), + (QIcon.Mode.Disabled, state), + (QIcon.Mode.Selected, state), + (QIcon.Mode.Disabled, opp_state), + (QIcon.Mode.Selected, opp_state), + ]: + opts = self._opts[s].get(m) + if opts: + return opts + return self._default_opts + + def paint( + self, + painter: QPainter, + rect: QRect, + mode: QIcon.Mode, + state: QIcon.State, + ) -> None: + opts = self._get_opts(state, mode) + + char, family, style = QFontIconStore.key2glyph(opts.glyph_key) + + # font + font = QFont() + font.setFamily(family) # set sepeartely for Qt6 + font.setPixelSize(round(rect.height() * opts.scale_factor)) + if style: + font.setStyleName(style) + + # color + if isinstance(opts.color, tuple): + color_args = opts.color + else: + color_args = (opts.color,) if opts.color else () # type: ignore + + # animation + if opts.animation is not None: + opts.animation.animate(painter) + + # animation + if opts.transform is not None: + painter.setTransform(opts.transform, True) + + painter.save() + painter.setPen(QColor(*color_args)) + painter.setOpacity(opts.opacity) + painter.setFont(font) + with QMessageHandler(): # avoid "Populating font family aliases" warning + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, char) + painter.restore() + + def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap: + # first look in cache + pmckey = self._pmcKey(size, mode, state) + pm = QPixmapCache.find(pmckey) if pmckey else None + if pm: + return pm + pixmap = QPixmap(size) + if not size.isValid(): + return pixmap + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + self.paint(painter, QRect(QPoint(0, 0), size), mode, state) + painter.end() + + # Apply palette-based styles for disabled/selected modes + # unless the user has specifically set a color for this mode/state + if mode != QIcon.Mode.Normal: + ico_opts = self._opts[state].get(mode) + if not ico_opts or not ico_opts.color: + opt = QStyleOption() + opt.palette = QGuiApplication.palette() + generated = QApplication.style().generatedIconPixmap(mode, pixmap, opt) + if not generated.isNull(): + pixmap = generated + + if pmckey and not pixmap.isNull(): + QPixmapCache.insert(pmckey, pixmap) + + return pixmap + + def _pmcKey(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> str: + # Qt6-style enums + if self._get_opts(state, mode).animation: + return "" + if hasattr(mode, "value"): + mode = mode.value + if hasattr(state, "value"): + state = state.value + k = ((((((size.width()) << 11) | size.height()) << 11) | mode) << 4) | state + return f"$superqt_{self._opt_hash}_{hex(k)}" + + def update_hash(self) -> None: + hsh = id(self) + for state, d in self._opts.items(): + for mode, opts in d.items(): + if not opts: + continue + hsh += hash( + hash(opts.glyph_key) + hash(opts.color) + hash(state) + hash(mode) + ) + self._opt_hash = hex(hsh) + + +class QFontIcon(QIcon): + def __init__(self, options: _IconOptions) -> None: + self._engine = _QFontIconEngine(options) + super().__init__(self._engine) + + def addState( + self, + state: QIcon.State = QIcon.State.Off, + mode: QIcon.Mode = QIcon.Mode.Normal, + glyph_key: Union[str, Unset] = _Unset, + scale_factor: Union[float, Unset] = _Unset, + color: Union[ValidColor, Unset] = _Unset, + opacity: Union[float, Unset] = _Unset, + animation: Union[Animation, Unset, None] = _Unset, + transform: Union[QTransform, Unset, None] = _Unset, + ) -> None: + """Set icon options for a specific mode/state.""" + if glyph_key is not _Unset: + QFontIconStore.key2glyph(glyph_key) # type: ignore + + _opts = IconOpts( + glyph_key=glyph_key, + scale_factor=scale_factor, + color=color, + opacity=opacity, + animation=animation, + transform=transform, + ) + self._engine._add_opts(state, mode, _opts) + + +class QFontIconStore(QObject): + + # map of key -> (font_family, font_style) + _LOADED_KEYS: Dict[str, Tuple[str, Optional[str]]] = dict() + + # map of (font_family, font_style) -> character (char may include key) + _CHARMAPS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = dict() + + # singleton instance, use `instance()` to retrieve + __instance: Optional[QFontIconStore] = None + + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent=parent) + # QT6 drops this + dpi = getattr(Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps", None) + if dpi: + QApplication.setAttribute(dpi) + + @classmethod + def instance(cls) -> QFontIconStore: + if cls.__instance is None: + cls.__instance = cls() + return cls.__instance + + @classmethod + def clear(cls) -> None: + cls._LOADED_KEYS.clear() + cls._CHARMAPS.clear() + QFontDatabase.removeAllApplicationFonts() + + @classmethod + def _key2family(cls, key: str) -> Tuple[str, Optional[str]]: + """Return (family, style) given a font `key`""" + key = key.split(".", maxsplit=1)[0] + if key not in cls._LOADED_KEYS: + from . import _plugins + + try: + font_cls = _plugins.get_font_class(key) + result = cls.addFont( + font_cls.__font_file__, key, charmap=font_cls.__dict__ + ) + if not result: # pragma: no cover + raise Exception("Invalid font file") + cls._LOADED_KEYS[key] = result + except ValueError as e: + raise ValueError( + f"Unrecognized font key: {key!r}.\n" + f"Known plugin keys include: {_plugins.available()}.\n" + f"Loaded keys include: {list(cls._LOADED_KEYS)}." + ) from e + return cls._LOADED_KEYS[key] + + @classmethod + def _ensure_char(cls, char: str, family: str, style: str) -> str: + """make sure that `char` is a glyph provided by `family` and `style`.""" + if len(char) == 1 and ord(char) > 256: + return char + try: + charmap = cls._CHARMAPS[(family, style)] + except KeyError: + raise KeyError(f"No charmap registered for font '{family} ({style})'") + if char in charmap: + # split in case the charmap includes the key + return charmap[char].split(".", maxsplit=1)[-1] + + ident = _ensure_identifier(char) + if ident in charmap: + return charmap[ident].split(".", maxsplit=1)[-1] + + ident = f"{char!r} or {ident!r}" if char != ident else repr(ident) + raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}") + + @classmethod + def key2glyph(cls, glyph_key: str) -> tuple[str, str, Optional[str]]: + """Return (char, family, style) given a `glyph_key`""" + if "." not in glyph_key: + raise ValueError("Glyph key must contain a period") + font_key, char = glyph_key.split(".", maxsplit=1) + family, style = cls._key2family(font_key) + char = cls._ensure_char(char, family, style) + return char, family, style + + @classmethod + def addFont( + cls, filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None + ) -> Optional[Tuple[str, str]]: + """Add font at `filepath` to the registry under `key`. + + If you'd like to later use a fontkey in the form of `key.some-name`, then + `charmap` must be provided and provide a mapping for all of the glyph names + to their unicode numbers. If a charmap is not provided, glyphs must be directly + accessed with their unicode as something like `key.\uffff`. + + Parameters + ---------- + filepath : str + Path to an OTF or TTF file containing the fonts + key : str + A key that will represent this font file when used for lookup. For example, + 'fa5s' for 'Font-Awesome 5 Solid'. + charmap : Dict[str, str], optional + optional mapping for all of the glyph names to their unicode numbers. + See note above. + + Returns + ------- + Tuple[str, str], optional + font-family and font-style for the file just registered, or None if + something goes wrong. + """ + if prefix in cls._LOADED_KEYS: + warnings.warn(f"Prefix {prefix} already loaded") + return + + if not Path(filepath).exists(): + raise FileNotFoundError(f"Font file doesn't exist: {filepath}") + if QApplication.instance() is None: + raise RuntimeError("Please create QApplication before adding a Font") + + fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute())) + if fontId < 0: # pragma: no cover + warnings.warn(f"Cannot load font file: {filepath}") + return None + + families = QFontDatabase.applicationFontFamilies(fontId) + if not families: # pragma: no cover + warnings.warn(f"Font file is empty!: {filepath}") + return None + family: str = families[0] + + # in Qt6, everything becomes a static member + QFd: Union[QFontDatabase, Type[QFontDatabase]] = ( + QFontDatabase() # type: ignore + if tuple(QT_VERSION.split(".")) < ("6", "0") + else QFontDatabase + ) + + styles = QFd.styles(family) # type: ignore + style: str = styles[-1] if styles else "" + if not QFd.isSmoothlyScalable(family, style): # pragma: no cover + warnings.warn( + f"Registered font {family} ({style}) is not smoothly scalable. " + "Icons may not look attractive." + ) + + cls._LOADED_KEYS[prefix] = (family, style) + if charmap: + cls._CHARMAPS[(family, style)] = charmap + return (family, style) + + def icon( + self, + glyph_key: str, + *, + scale_factor: float = DEFAULT_SCALING_FACTOR, + color: ValidColor = None, + opacity: float = 1, + animation: Optional[Animation] = None, + transform: Optional[QTransform] = None, + states: Dict[str, Union[IconOptionDict, IconOpts]] = {}, + ) -> QFontIcon: + self.key2glyph(glyph_key) # make sure it's a valid glyph_key + default_opts = _IconOptions( + glyph_key=glyph_key, + scale_factor=scale_factor, + color=color, + opacity=opacity, + animation=animation, + transform=transform, + ) + icon = QFontIcon(default_opts) + for kw, options in states.items(): + if isinstance(options, IconOpts): + options = default_opts._update(options).dict() + icon.addState(*_norm_state_mode(kw), **options) + return icon + + def setTextIcon( + self, widget: QWidget, glyph_key: str, size: Optional[float] = None + ) -> None: + """Sets text on a widget to a specific font & glyph. + + This is an alternative to setting a QIcon with a pixmap. It may + be easier to combine with dynamic stylesheets. + """ + setText = getattr(widget, "setText", None) + if not setText: # pragma: no cover + raise TypeError(f"Object does not a setText method: {widget}") + + glyph = self.key2glyph(glyph_key)[0] + size = size or DEFAULT_SCALING_FACTOR + size = size if size > 1 else widget.height() * size + widget.setFont(self.font(glyph_key, int(size))) + setText(glyph) + + def font(self, font_prefix: str, size: Optional[int] = None) -> QFont: + """Create QFont for `font_prefix`""" + font_key, _ = font_prefix.split(".", maxsplit=1) + family, style = self._key2family(font_key) + font = QFont() + font.setFamily(family) + if style: + font.setStyleName(style) + if size: + font.setPixelSize(int(size)) + return font + + +def _ensure_identifier(name: str) -> str: + """Normalize string to valid identifier""" + import keyword + + if not name: + return "" + + # add _ to beginning of names starting with numbers + if name[0].isdigit(): + name = f"_{name}" + + # add _ to end of reserved keywords + if keyword.iskeyword(name): + name += "_" + + # replace dashes and spaces with underscores + name = name.replace("-", "_").replace(" ", "_") + + assert str.isidentifier(name), f"Could not canonicalize name: {name}" + return name diff --git a/src/superqt/fonticon/_tests/icontest.ttf b/src/superqt/fonticon/_tests/icontest.ttf new file mode 100644 index 0000000..be2b7bd Binary files /dev/null and b/src/superqt/fonticon/_tests/icontest.ttf differ diff --git a/src/superqt/fonticon/_tests/test_fonticon.py b/src/superqt/fonticon/_tests/test_fonticon.py new file mode 100644 index 0000000..734533b --- /dev/null +++ b/src/superqt/fonticon/_tests/test_fonticon.py @@ -0,0 +1,135 @@ +from pathlib import Path + +import pytest + +from superqt.fonticon import icon, pulse, setTextIcon, spin +from superqt.fonticon._qfont_icon import QFontIconStore, _ensure_identifier +from superqt.qtcompat.QtGui import QIcon, QPixmap +from superqt.qtcompat.QtWidgets import QPushButton + +TEST_PREFIX = "ico" +TEST_CHARNAME = "smiley" +TEST_CHAR = "\ue900" +TEST_GLYPHKEY = f"{TEST_PREFIX}.{TEST_CHARNAME}" +FONT_FILE = Path(__file__).parent / "icontest.ttf" + + +@pytest.fixture +def store(qapp): + store = QFontIconStore().instance() + yield store + store.clear() + + +@pytest.fixture +def full_store(store): + store.addFont(str(FONT_FILE), TEST_PREFIX, {TEST_CHARNAME: TEST_CHAR}) + return store + + +def test_no_font_key(): + with pytest.raises(KeyError) as err: + icon(TEST_GLYPHKEY) + assert "Unrecognized font key: {TEST_PREFIX!r}." in str(err) + + +def test_no_charmap(store): + store.addFont(str(FONT_FILE), TEST_PREFIX) + with pytest.raises(KeyError) as err: + icon(TEST_GLYPHKEY) + assert "No charmap registered for" in str(err) + + +def test_font_icon_works(full_store): + icn = icon(TEST_GLYPHKEY) + assert isinstance(icn, QIcon) + assert isinstance(icn.pixmap(40, 40), QPixmap) + + icn = icon(f"{TEST_PREFIX}.{TEST_CHAR}") # also works with unicode key + assert isinstance(icn, QIcon) + assert isinstance(icn.pixmap(40, 40), QPixmap) + + with pytest.raises(ValueError) as err: + icon(f"{TEST_PREFIX}.smelly") # bad name + assert "Font 'test (Regular)' has no glyph with the key 'smelly'" in str(err) + + +def test_on_button(full_store, qtbot): + btn = QPushButton(None) + qtbot.addWidget(btn) + btn.setIcon(icon(TEST_GLYPHKEY)) + + +def test_btn_text_icon(full_store, qtbot): + btn = QPushButton(None) + qtbot.addWidget(btn) + setTextIcon(btn, TEST_GLYPHKEY) + assert btn.text() == TEST_CHAR + + +def test_animation(full_store, qtbot): + btn = QPushButton(None) + qtbot.addWidget(btn) + icn = icon(TEST_GLYPHKEY, animation=pulse(btn)) + btn.setIcon(icn) + with qtbot.waitSignal(icn._engine._default_opts.animation.timer.timeout): + icn.pixmap(40, 40) + btn.update() + + +def test_multistate(full_store, qtbot, qapp): + """complicated multistate icon""" + btn = QPushButton() + qtbot.addWidget(btn) + icn = icon( + TEST_GLYPHKEY, + color="blue", + states={ + "active": { + "color": "red", + "scale_factor": 0.5, + "animation": pulse(btn), + }, + "disabled": { + "color": "green", + "scale_factor": 0.8, + "animation": spin(btn), + }, + }, + ) + btn.setIcon(icn) + btn.show() + + btn.setEnabled(False) + active = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Active].animation.timer + disabled = icn._engine._opts[QIcon.State.Off][QIcon.Mode.Disabled].animation.timer + + with qtbot.waitSignal(active.timeout, timeout=1000): + btn.setEnabled(True) + # hack to get the signal emitted + icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) + + assert active.isActive() + assert not disabled.isActive() + with qtbot.waitSignal(disabled.timeout): + btn.setEnabled(False) + assert disabled.isActive() + + # smoke test, paint all the states + icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.Off) + icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.Off) + icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.Off) + icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.Off) + icn.pixmap(100, 100, QIcon.Mode.Active, QIcon.State.On) + icn.pixmap(100, 100, QIcon.Mode.Disabled, QIcon.State.On) + icn.pixmap(100, 100, QIcon.Mode.Selected, QIcon.State.On) + icn.pixmap(100, 100, QIcon.Mode.Normal, QIcon.State.On) + + +def test_ensure_identifier(): + assert _ensure_identifier("") == "" + assert _ensure_identifier("1a") == "_1a" + assert _ensure_identifier("from") == "from_" + assert _ensure_identifier("hello-world") == "hello_world" + assert _ensure_identifier("hello_world") == "hello_world" + assert _ensure_identifier("hello world") == "hello_world" diff --git a/src/superqt/fonticon/_tests/test_plugins.py b/src/superqt/fonticon/_tests/test_plugins.py new file mode 100644 index 0000000..4fe648e --- /dev/null +++ b/src/superqt/fonticon/_tests/test_plugins.py @@ -0,0 +1,54 @@ +import sys +from pathlib import Path + +import pytest + +from superqt.fonticon import _plugins, icon +from superqt.fonticon._qfont_icon import QFontIconStore +from superqt.qtcompat.QtGui import QIcon, QPixmap + +try: + from importlib.metadata import Distribution +except ImportError: + from importlib_metadata import Distribution # type: ignore + + +class ICO: + __font_file__ = str(Path(__file__).parent / "icontest.ttf") + smiley = "ico.\ue900" + + +@pytest.fixture +def plugin_store(qapp, monkeypatch): + class MockEntryPoint: + name = "ico" + group = _plugins.FontIconManager.ENTRY_POINT + value = "fake_plugin.ICO" + + def load(self): + return ICO + + class MockFinder: + def find_distributions(self, *a): + class D(Distribution): + name = "mock" + + @property + def entry_points(self): + return [MockEntryPoint()] + + return [D()] + + store = QFontIconStore().instance() + with monkeypatch.context() as m: + m.setattr(sys, "meta_path", [MockFinder()]) + yield store + store.clear() + + +def test_plugin(plugin_store): + assert not _plugins.loaded() + icn = icon("ico.smiley") + assert _plugins.loaded() == {"ico": ["smiley"]} + assert isinstance(icn, QIcon) + assert isinstance(icn.pixmap(40, 40), QPixmap) diff --git a/src/superqt/qtcompat/QtWidgets.py b/src/superqt/qtcompat/QtWidgets.py index 73fe0b5..09a8397 100644 --- a/src/superqt/qtcompat/QtWidgets.py +++ b/src/superqt/qtcompat/QtWidgets.py @@ -7,11 +7,7 @@ globals().update(_QtWidgets.__dict__) QApplication = _QtWidgets.QApplication if not hasattr(QApplication, "exec"): - - def exec_(self): - _QtWidgets.QApplication.exec(self) - - QApplication.exec = exec_ + QApplication.exec = _QtWidgets.QApplication.exec_ # backwargs compat with qt5 if "6" in API_NAME: