Add font icons (#24)

* working kinda well

* rearrange

* add back init

* change entrypoints

* add mi4

* example

* more improvements

* add animations

* more changes

* add feather, improve seticon

* update example

* refactor

* broken wip

* use iconfontmeta instead of enum

* mostly working

* misc

* more tweaks

* docs

* adding tests

* remove napari example

* more docs

* more docs

* update examples

* more docs

* typing

* working on icon options

* updates

* update

* update example

* update tests

* add comment

* docs

* fix annotation

* try set false first

* fix py37

* more test fixes

* fix qt6 test

* ignore old deprecation warning

* extend test
This commit is contained in:
Talley Lambert
2021-11-15 10:55:46 -05:00
committed by GitHub
parent e1d2edb204
commit 8001022e18
15 changed files with 1659 additions and 6 deletions

0
docs/fonticon.md Normal file
View File

20
examples/fonticon1.py Normal file
View File

@@ -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()

20
examples/fonticon2.py Normal file
View File

@@ -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()

40
examples/fonticon3.py Normal file
View File

@@ -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()

377
examples/icon_explorer.py Normal file
View File

@@ -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("<b>%s</b>" % 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_())

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
}

View File

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

Binary file not shown.

View File

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

View File

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

View File

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