"""Flow layout that automatically wraps widgets based on available width.
This module provides a flow layout that arranges widgets in a flowing manner,
similar to how text wraps in a word processor. Widgets automatically wrap to
the next line when they exceed the available width.
Layout Structure:
Widgets flow left to right and wrap when necessary::
Available Width
┌─────────────────────────────────┐
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ W1 │ │ W2 │ │ W3 │ │ W4 │ │
│ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ W5 │ │ W6 │ │ W7 │ │
│ └────┘ └────┘ └────┘ │
│ ┌────┐ │
│ │ W8 │ │
│ └────┘ │
└─────────────────────────────────┘
When Width Decreases:
┌───────────────┐
│ ┌────┐ ┌────┐│
│ │ W1 │ │ W2 ││
│ └────┘ └────┘│
│ ┌────┐ ┌────┐│
│ │ W3 │ │ W4 ││
│ └────┘ └────┘│
│ ┌────┐ ┌────┐│
│ │ W5 │ │ W6 ││
│ └────┘ └────┘│
└───────────────┘
Example:
Create a flow layout with tags or chips::
from qtframework.layouts.flow import FlowLayout
from qtframework.widgets import Badge
from PySide6.QtWidgets import QWidget
# Create container with flow layout
container = QWidget()
flow_layout = FlowLayout(parent=container, margin=10, h_spacing=8, v_spacing=8)
# Add tags/chips that will wrap automatically
tags = [
"Python",
"JavaScript",
"React",
"Qt",
"Django",
"FastAPI",
"PostgreSQL",
"Redis",
"Docker",
]
for tag in tags:
badge = Badge(text=tag, variant="primary")
flow_layout.addWidget(badge)
# Layout automatically reflows on window resize
Use with button groups::
from qtframework.widgets import Button
# Create action buttons that wrap on small screens
actions = ["Save", "Cancel", "Delete", "Export", "Print"]
for action in actions:
button = Button(text=action)
flow_layout.addWidget(button)
Custom spacing based on widget properties::
# Create layout with tight spacing
tight_flow = FlowLayout(h_spacing=4, v_spacing=4)
# Create layout with generous spacing
loose_flow = FlowLayout(h_spacing=16, v_spacing=16)
Key Features:
- **Automatic Wrapping**: Widgets wrap to next line when width exceeded
- **Dynamic Reflow**: Automatically adjusts on window resize
- **Custom Spacing**: Configure horizontal and vertical spacing
- **Height for Width**: Properly calculates height based on width
- **Smart Spacing**: Uses platform-appropriate spacing when not specified
See Also:
:class:`SidebarLayout`: Sidebar layout with collapsible panel
:class:`qtframework.widgets.Badge`: Badge widget often used with flow layout
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QStyle, QWidget
if TYPE_CHECKING:
from PySide6.QtWidgets import QLayoutItem
[docs]
class FlowLayout(QLayout):
"""A layout that arranges widgets in a flowing manner, wrapping to new lines as needed."""
def __init__(
self,
parent: QWidget | None = None,
margin: int = -1,
h_spacing: int = -1,
v_spacing: int = -1,
) -> None:
"""Initialize the flow layout.
Args:
parent: Parent widget
margin: Margin around the layout
h_spacing: Horizontal spacing between widgets
v_spacing: Vertical spacing between widgets
"""
super().__init__(parent)
self._item_list: list[QLayoutItem] = []
self._h_space = h_spacing
self._v_space = v_spacing
if margin != -1:
self.setContentsMargins(margin, margin, margin, margin)
def __del__(self) -> None:
"""Clean up layout items."""
while self._item_list:
self.takeAt(0)
[docs]
def addItem(self, item: QLayoutItem) -> None:
"""Add an item to the layout."""
self._item_list.append(item)
[docs]
def horizontalSpacing(self) -> int:
"""Get horizontal spacing between widgets."""
if self._h_space >= 0:
return self._h_space
return self._smart_spacing(horizontal=True)
[docs]
def verticalSpacing(self) -> int:
"""Get vertical spacing between widgets."""
if self._v_space >= 0:
return self._v_space
return self._smart_spacing(horizontal=False)
[docs]
def count(self) -> int:
"""Return the number of items in the layout."""
return len(self._item_list)
[docs]
def itemAt(self, index: int) -> QLayoutItem | None:
"""Return the item at the given index."""
if 0 <= index < len(self._item_list):
return self._item_list[index]
return None
[docs]
def takeAt(self, index: int) -> QLayoutItem:
"""Remove and return the item at the given index."""
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
return cast("QLayoutItem", None)
[docs]
def expandingDirections(self) -> Qt.Orientation:
"""Return the expanding directions."""
return Qt.Orientation(0)
[docs]
def hasHeightForWidth(self) -> bool:
"""Return whether the layout has height for width."""
return True
[docs]
def heightForWidth(self, width: int) -> int:
"""Calculate the height needed for a given width."""
if width <= 0:
return int(self.minimumSize().height())
height = self._do_layout(QRect(0, 0, width, 0), test=True)
return int(max(height, self.minimumSize().height()))
[docs]
def setGeometry(self, rect: QRect) -> None:
"""Set the geometry of the layout."""
super().setGeometry(rect)
self._do_layout(rect, test=False)
[docs]
def invalidate(self) -> None:
"""Invalidate the layout to force recalculation.
Triggers immediate re-layout if geometry is already valid to ensure
proper widget positioning after invalidation.
"""
super().invalidate()
if self.geometry().isValid():
self._do_layout(self.geometry(), test=False)
[docs]
def sizeHint(self) -> QSize:
"""Return the preferred size of the layout."""
return self.minimumSize()
[docs]
def minimumSize(self) -> QSize:
"""Calculate 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 _do_layout(self, rect: QRect, test: bool = False) -> int:
"""Perform the actual layout of items.
Args:
rect: The rectangle to layout within
test: If True, don't actually move widgets, just calculate height
Returns:
The height used by the layout
"""
left = top = right = bottom = 0
margins = cast("tuple[int, int, int, int]", self.getContentsMargins())
if margins:
left, top, right, bottom = margins
effective_rect = rect.adjusted(left, top, -right, -bottom)
# Fallback to parent width if layout width not yet determined
if effective_rect.width() <= 0:
parent_widget = self.parentWidget()
if parent_widget is not None:
effective_rect.setWidth(parent_widget.width() - left - right)
if effective_rect.width() <= 0:
return 0
x = effective_rect.x()
y = effective_rect.y()
line_height = 0
h_space = self.horizontalSpacing()
v_space = self.verticalSpacing()
for item in self._item_list:
widget = item.widget()
if widget and not widget.isVisible():
continue
space_x = h_space
space_y = v_space
item_size = item.sizeHint()
next_x = x + item_size.width() + space_x
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_size.width() + space_x
line_height = 0
if not test:
item.setGeometry(QRect(QPoint(x, y), item_size))
if widget:
widget.show()
widget.raise_()
x = next_x
line_height = max(line_height, item_size.height())
return int(y + line_height - rect.y() + bottom)
def _smart_spacing(self, *, horizontal: bool) -> int:
"""Calculate smart spacing based on parent widget style."""
parent = self.parent()
if not isinstance(parent, QWidget):
return -1
style = parent.style()
if horizontal:
metric = style.pixelMetric(
QStyle.PixelMetric.PM_LayoutHorizontalSpacing,
None,
parent,
)
else:
metric = style.pixelMetric(
QStyle.PixelMetric.PM_LayoutVerticalSpacing,
None,
parent,
)
return int(metric)