diff --git a/docs/widgets/index.md b/docs/widgets/index.md index c3409e7f..ef7250e3 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -33,3 +33,4 @@ The following are QWidget subclasses: | Widget | Description | | ----------- | --------------------- | | [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. | +| [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. | diff --git a/docs/widgets/qflowlayout.md b/docs/widgets/qflowlayout.md new file mode 100644 index 00000000..74e5f88c --- /dev/null +++ b/docs/widgets/qflowlayout.md @@ -0,0 +1,29 @@ +# QFlowLayout + +QLayout that rearranges items based on parent width. + +```python +from qtpy.QtWidgets import QApplication, QPushButton, QWidget + +from superqt import QFlowLayout + +app = QApplication([]) + +wdg = QWidget() + +layout = QFlowLayout(wdg) +layout.addWidget(QPushButton("Short")) +layout.addWidget(QPushButton("Longer")) +layout.addWidget(QPushButton("Different text")) +layout.addWidget(QPushButton("More text")) +layout.addWidget(QPushButton("Even longer button text")) + +wdg.setWindowTitle("Flow Layout") +wdg.show() + +app.exec() +``` + +{{ show_widget(350) }} + +{{ show_members('superqt.QFlowLayout') }} diff --git a/examples/flow_layout.py b/examples/flow_layout.py new file mode 100644 index 00000000..8423aa2d --- /dev/null +++ b/examples/flow_layout.py @@ -0,0 +1,19 @@ +from qtpy.QtWidgets import QApplication, QPushButton, QWidget + +from superqt import QFlowLayout + +app = QApplication([]) + +wdg = QWidget() + +layout = QFlowLayout(wdg) +layout.addWidget(QPushButton("Short")) +layout.addWidget(QPushButton("Longer")) +layout.addWidget(QPushButton("Different text")) +layout.addWidget(QPushButton("More text")) +layout.addWidget(QPushButton("Even longer button text")) + +wdg.setWindowTitle("Flow Layout") +wdg.show() + +app.exec() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 8d37b63a..f5bad84a 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -23,7 +23,12 @@ QRangeSlider, ) from .spinbox import QLargeIntSpinBox -from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread +from .utils import ( + QFlowLayout, + QMessageHandler, + ensure_main_thread, + ensure_object_thread, +) __all__ = [ "QCollapsible", @@ -34,6 +39,7 @@ "QElidingLabel", "QElidingLineEdit", "QEnumComboBox", + "QFlowLayout", "QIconifyIcon", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", diff --git a/src/superqt/utils/__init__.py b/src/superqt/utils/__init__.py index f3fcfebc..2d12cc4e 100644 --- a/src/superqt/utils/__init__.py +++ b/src/superqt/utils/__init__.py @@ -7,6 +7,7 @@ "CodeSyntaxHighlight", "FunctionWorker", "GeneratorWorker", + "QFlowLayout", "QMessageHandler", "QSignalDebouncer", "QSignalThrottler", @@ -27,6 +28,7 @@ from ._code_syntax_highlight import CodeSyntaxHighlight from ._ensure_thread import ensure_main_thread, ensure_object_thread from ._errormsg_context import exceptions_as_dialog +from ._flow_layout import QFlowLayout from ._img_utils import qimage_to_array from ._message_handler import QMessageHandler from ._misc import signals_blocked diff --git a/src/superqt/utils/_flow_layout.py b/src/superqt/utils/_flow_layout.py new file mode 100644 index 00000000..2c8babdc --- /dev/null +++ b/src/superqt/utils/_flow_layout.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from qtpy.QtCore import QPoint, QRect, QSize, Qt +from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget + + +class QFlowLayout(QLayout): + """Layout that handles different window sizes. + + The widget placement changes depending on the width of the application window. + + Code translated from C++ at: + + + described at: + + See also: + + Parameters + ---------- + parent : QWidget, optional + The parent widget, by default None + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self._item_list: list[QLayoutItem] = [] + self._h_space = -1 + self._v_space = -1 + + def __del__(self) -> None: + while item := self.takeAt(0): + del item + + def addItem(self, item: QLayoutItem | None) -> None: + """Add an item to the layout.""" + if item: + self._item_list.append(item) + + def setHorizontalSpacing(self, space: int | None) -> None: + """Set the horizontal spacing. + + If None or -1, the spacing is set to the default value based on the style + of the parent widget. + """ + self._h_space = -1 if space is None else space + + def horizontalSpacing(self) -> int: + """Return the horizontal spacing.""" + if self._h_space >= 0: + return self._h_space + return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing) + + def setVerticalSpacing(self, space: int | None) -> None: + """Set the vertical spacing. + + If None or -1, the spacing is set to the default value based on the style + of the parent widget. + """ + self._v_space = -1 if space is None else space + + def verticalSpacing(self) -> int: + """Return the vertical spacing.""" + if self._v_space >= 0: + return self._v_space + return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing) + + def expandingDirections(self) -> Qt.Orientation: + """Return the expanding directions. + + These are the Qt::Orientations in which the layout can make use of more space + than its sizeHint(). + """ + return Qt.Orientation.Horizontal + + def hasHeightForWidth(self) -> bool: + """Return whether the layout handles height for width.""" + return True + + def heightForWidth(self, width: int) -> int: + """Return the height for a given width. + + `heightForWidth()` passes the width on to _doLayout() which in turn uses the + width as an argument for the layout rect, i.e., the bounds in which the items + are laid out. This rect does not include the layout margin(). + """ + return self._doLayout(QRect(0, 0, width, 0), True) + + def count(self) -> int: + """Return the number of items in the layout.""" + return len(self._item_list) + + def itemAt(self, index: int) -> QLayoutItem | None: + """Return the item at the given index, or None if the index is out of range.""" + try: + return self._item_list[index] + except IndexError: + return None + + def minimumSize(self) -> QSize: + """Return the minimum size of the layout.""" + size = QSize() + for item in self._item_list: + size = size.expandedTo(item.minimumSize()) + + margins = self.contentsMargins() + size += QSize( + margins.left() + margins.right(), margins.top() + margins.bottom() + ) + return size + + def setGeometry(self, rect: QRect) -> None: + """Set the geometry of the layout. + + This triggers a re-layout of the items. + """ + super().setGeometry(rect) + self._doLayout(rect) + + def sizeHint(self) -> QSize: + """Return the size hint of the layout.""" + return self.minimumSize() + + def takeAt(self, index: int) -> QLayoutItem | None: + """Remove and return the item at the given index. Or return None.""" + if 0 <= index < len(self._item_list): + return self._item_list.pop(index) + return None + + def _doLayout(self, rect: QRect, test_only: bool = False) -> int: + """Arrange the items in the layout. + + If test_only is True, the items are not actually laid out, but the height + that the layout would have with the given width is returned. + """ + left, top, right, bottom = self.getContentsMargins() + effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore + x = effective_rect.x() + y = effective_rect.y() + line_height = 0 + + for item in self._item_list: + if (wid := item.widget()) and (style := wid.style()): + space_x = self.horizontalSpacing() + space_y = self.verticalSpacing() + if space_x == -1: + space_x = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, + ) + if space_y == -1: + space_y = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, + ) + + # next_x is the x-coordinate of the right edge of the item + next_x = x + item.sizeHint().width() + space_x + # if the item is not the first one in a line, add the spacing + # to the left of it + if next_x - space_x > effective_rect.right() and line_height > 0: + x = effective_rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + # if this is not a test run, move the item to its proper place + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + bottom + + def _smartSpacing(self, pm: QStyle.PixelMetric) -> int: + """Return the smart spacing based on the style of the parent widget.""" + if isinstance(parent := self.parent(), QWidget) and (style := parent.style()): + return style.pixelMetric(pm, None, parent) + return -1 diff --git a/tests/test_flow_layout.py b/tests/test_flow_layout.py new file mode 100644 index 00000000..39fbc1ee --- /dev/null +++ b/tests/test_flow_layout.py @@ -0,0 +1,27 @@ +from typing import Any + +from qtpy.QtWidgets import QPushButton, QWidget + +from superqt import QFlowLayout + + +def test_flow_layout(qtbot: Any) -> None: + wdg = QWidget() + qtbot.addWidget(wdg) + + layout = QFlowLayout(wdg) + layout.addWidget(QPushButton("Short")) + layout.addWidget(QPushButton("Longer")) + layout.addWidget(QPushButton("Different text")) + layout.addWidget(QPushButton("More text")) + layout.addWidget(QPushButton("Even longer button text")) + + wdg.setWindowTitle("Flow Layout") + wdg.show() + + assert layout.expandingDirections() + assert layout.heightForWidth(200) > layout.heightForWidth(400) + assert layout.count() == 5 + assert layout.itemAt(0).widget().text() == "Short" + layout.takeAt(0) + assert layout.count() == 4