Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Change icon used in Collapsible widget #140

Merged
merged 20 commits into from
Dec 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/qcollapsible.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
app = QApplication([])

collapsible = QCollapsible("Advanced analysis")
collapsible.setCollapsedIcon("+")
collapsible.setExpandedIcon("-")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
Expand Down
93 changes: 70 additions & 23 deletions src/superqt/collapsible/_collapsible.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
from typing import Optional, Union

from qtpy.QtCore import (
QEasingCurve,
QEvent,
QMargins,
QObject,
QPropertyAnimation,
QRect,
Qt,
Signal,
)
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget


Expand All @@ -21,18 +23,24 @@ class QCollapsible(QFrame):
Based on https://stackoverflow.com/a/68141638
"""

_EXPANDED = "▼ "
_COLLAPSED = "▲ "

toggled = Signal(bool)

def __init__(self, title: str = "", parent: Optional[QWidget] = None):
def __init__(
self,
title: str = "",
parent: Optional[QWidget] = None,
expandedIcon: Optional[Union[QIcon, str]] = "▼",
collapsedIcon: Optional[Union[QIcon, str]] = "▲",
):
super().__init__(parent)
self._locked = False
self._is_animating = False
self._text = title

self._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn = QPushButton(title)
self._toggle_btn.setCheckable(True)
self.setCollapsedIcon(icon=collapsedIcon)
self.setExpandedIcon(icon=expandedIcon)
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
self._toggle_btn.toggled.connect(self._toggle)

Expand All @@ -56,16 +64,16 @@ def __init__(self, title: str = "", parent: Optional[QWidget] = None):
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
self.setContent(_content)

def setText(self, text: str):
def setText(self, text: str) -> None:
"""Set the text of the toggle button."""
current = self._toggle_btn.text()[: len(self._EXPANDED)]
current = self._toggle_btn.text()
self._toggle_btn.setText(current + text)

def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()[len(self._EXPANDED) :]
return self._toggle_btn.text()

def setContent(self, content: QWidget):
def setContent(self, content: QWidget) -> None:
"""Replace central widget (the widget that gets expanded/collapsed)."""
self._content = content
self.layout().addWidget(self._content)
Expand All @@ -75,37 +83,77 @@ def content(self) -> QWidget:
"""Return the current content widget."""
return self._content

def setDuration(self, msecs: int):
def _convert_string_to_icon(self, symbol: str) -> QIcon:
"""Create a QIcon from a string."""
size = self._toggle_btn.font().pointSize()
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
painter.setPen(color)
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
painter.end()
return QIcon(pixmap)

def expandedIcon(self) -> QIcon:
"""Returns the icon used when the widget is expanded."""
return self._expanded_icon

ppwadhwa marked this conversation as resolved.
Show resolved Hide resolved
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
"""Set the icon on the toggle button when the widget is expanded."""
if icon and isinstance(icon, QIcon):
self._expanded_icon = icon
elif icon and isinstance(icon, str):
self._expanded_icon = self._convert_string_to_icon(icon)

if self.isExpanded():
self._toggle_btn.setIcon(self._expanded_icon)

def collapsedIcon(self) -> QIcon:
"""Returns the icon used when the widget is collapsed."""
return self._collapsed_icon

ppwadhwa marked this conversation as resolved.
Show resolved Hide resolved
def setCollapsedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
"""Set the icon on the toggle button when the widget is collapsed."""
if icon and isinstance(icon, QIcon):
self._collapsed_icon = icon
elif icon and isinstance(icon, str):
self._collapsed_icon = self._convert_string_to_icon(icon)

if not self.isExpanded():
self._toggle_btn.setIcon(self._collapsed_icon)

def setDuration(self, msecs: int) -> None:
"""Set duration of the collapse/expand animation."""
self._animation.setDuration(msecs)

def setEasingCurve(self, easing: QEasingCurve):
def setEasingCurve(self, easing: QEasingCurve) -> None:
"""Set the easing curve for the collapse/expand animation"""
self._animation.setEasingCurve(easing)

def addWidget(self, widget: QWidget):
def addWidget(self, widget: QWidget) -> None:
"""Add a widget to the central content widget's layout."""
widget.installEventFilter(self)
self._content.layout().addWidget(widget)

def removeWidget(self, widget: QWidget):
def removeWidget(self, widget: QWidget) -> None:
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
widget.removeEventFilter(self)

def expand(self, animate: bool = True):
def expand(self, animate: bool = True) -> None:
"""Expand (show) the collapsible section"""
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)

def collapse(self, animate: bool = True):
def collapse(self, animate: bool = True) -> None:
"""Collapse (hide) the collapsible section"""
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)

def isExpanded(self) -> bool:
"""Return whether the collapsible section is visible"""
return self._toggle_btn.isChecked()

def setLocked(self, locked: bool = True):
def setLocked(self, locked: bool = True) -> None:
"""Set whether collapse/expand is disabled"""
self._locked = locked
self._toggle_btn.setCheckable(not locked)
Expand All @@ -119,7 +167,7 @@ def _expand_collapse(
direction: QPropertyAnimation.Direction,
animate: bool = True,
emit: bool = True,
):
) -> None:
"""Set values for the widget based on whether it is expanding or collapsing.

An emit flag is included so that the toggle signal is only called once (it
Expand All @@ -129,10 +177,9 @@ def _expand_collapse(
return

forward = direction == QPropertyAnimation.Direction.Forward
text = self._EXPANDED if forward else self._COLLAPSED

icon = self._expanded_icon if forward else self._collapsed_icon
self._toggle_btn.setIcon(icon)
self._toggle_btn.setChecked(forward)
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])

_content_height = self._content.sizeHint().height() + 10
if animate:
Expand All @@ -145,7 +192,7 @@ def _expand_collapse(
if emit:
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)

def _toggle(self):
def _toggle(self) -> None:
self.expand() if self.isExpanded() else self.collapse()

def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
Expand All @@ -160,5 +207,5 @@ def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
)
return False

def _on_animation_done(self):
def _on_animation_done(self) -> None:
self._is_animating = False
43 changes: 41 additions & 2 deletions tests/test_collapsible.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
"""A test module for testing collapsible"""

from qtpy.QtCore import QEasingCurve, Qt
from qtpy.QtWidgets import QPushButton
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QPushButton, QStyle, QWidget

from superqt import QCollapsible


def _get_builtin_icon(name: str) -> QIcon:
"""Get a built-in icon from the Qt library."""
widget = QWidget()
try:
pixmap = getattr(QStyle.StandardPixmap, f"SP_{name}")
except AttributeError:
pixmap = getattr(QStyle, f"SP_{name}")

return widget.style().standardIcon(pixmap)


def test_checked_initialization(qtbot):
"""Test simple collapsible"""
wdg1 = QCollapsible("Advanced analysis")
Expand Down Expand Up @@ -84,7 +96,7 @@ def test_changing_text(qtbot):
wdg = QCollapsible()
wdg.setText("Hi new text")
assert wdg.text() == "Hi new text"
assert wdg._toggle_btn.text() == QCollapsible._COLLAPSED + "Hi new text"
assert wdg._toggle_btn.text() == "Hi new text"
ppwadhwa marked this conversation as resolved.
Show resolved Hide resolved


def test_toggle_signal(qtbot):
Expand All @@ -98,3 +110,30 @@ def test_toggle_signal(qtbot):

with qtbot.waitSignal(wdg.toggled, timeout=500):
wdg.collapse()


def test_getting_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
assert isinstance(wdg.expandedIcon(), QIcon)
assert isinstance(wdg.collapsedIcon(), QIcon)


def test_setting_icon(qtbot):
"""Test setting icon for toggle button."""
icon1 = _get_builtin_icon("ArrowRight")
icon2 = _get_builtin_icon("ArrowDown")
wdg = QCollapsible("test", expandedIcon=icon1, collapsedIcon=icon2)
assert wdg._expanded_icon == icon1
assert wdg._collapsed_icon == icon2


def test_setting_symbol_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
icon1 = wdg._convert_string_to_icon("+")
icon2 = wdg._convert_string_to_icon("-")
wdg.setCollapsedIcon(icon=icon1)
assert wdg._collapsed_icon == icon1
wdg.setExpandedIcon(icon=icon2)
assert wdg._expanded_icon == icon2