CardGridView.py PySideAbdhUI — Card Grid with Infinite Scroll & Selection
CardGridView.py provides two tightly-coupled widget classes that together implement a
scrollable, selectable card grid with infinite-scroll loading support. The module is designed
for scenarios where the user needs to browse a collection of items displayed as uniform cards
in a responsive grid layout — such as product catalogs, media libraries, search results, or
dashboard panels. The architecture separates the visual representation of an individual card
(CardWidget) from the grid container that manages layout, selection, and data loading
(CardGridView).
The CardWidget class wraps any arbitrary QWidget inside a styled card frame,
providing click detection and a visual selection toggle. The CardGridView class manages
a dictionary of cards indexed by integer IDs, handles single-selection semantics, supports
dynamic column reconfiguration, and implements an infinite-scroll pattern that emits a signal
when the user scrolls near the bottom, allowing the parent to load additional data. Auxiliary
UI states — loading indicators, "Load More" buttons, and empty-state messages — are managed
internally and shown/hidden as appropriate.
Dependencies
| Module | Components Used | Purpose |
|---|---|---|
PySide6.QtWidgets |
QWidget, QVBoxLayout, QGridLayout, QScrollArea, QLabel, QPushButton |
Core widget classes for building the card grid, scroll container, and auxiliary UI elements. |
PySide6.QtCore |
Qt, Signal |
Alignment/enum flags and the signal/slot system for event communication. |
typing |
List, Dict, Optional |
Type annotations for method signatures and internal data structures. |
Architecture
The module follows a container-item pattern where CardGridView owns and manages
CardWidget instances. Each card wraps an arbitrary user-provided widget and sits
inside a grid layout within a scroll area. The following diagram illustrates the widget
hierarchy:
Each CardWidget uses a QGridLayout with both the background layer and the
user widget placed at position [0,0]. Since QGridLayout stacks widgets at the
same cell in Z-order (later additions on top), the user widget is visually above the
background layer. The background layer's CSS class changes between card and
card-selected to indicate selection state, while the user widget always remains
interactive on top.
CardWidget
CardWidget
QWidget
A wrapper widget that encapsulates any user-provided QWidget inside a styled
card frame with click detection and visual selection toggling. The card consists of
two layers stacked at the same grid position: a background_layer widget whose
CSS class switches between card and card-selected, and the user
widget that sits on top. When the card is clicked, the clicked signal is
emitted, allowing the parent CardGridView to handle selection logic.
The selection state is purely visual — toggling selection changes the background
layer's CSS class and forces a style re-polish so the new class takes effect
immediately. The card does not enforce any selection policy itself; that is the
responsibility of CardGridView.
Constructor
CardWidget.__init__(self, widget: QWidget, parent=None)
Public
Creates a card wrapping the given widget. The widget is stored as
self.widget for later access and replacement. The selection state is
initialized to False, and setup_ui() is called to build the
visual structure.
| Parameter | Type | Description |
|---|---|---|
widget | QWidget | The user-defined widget to display inside the card. This can be any QWidget subclass — a form, an image viewer, a custom layout, etc. |
parent | QWidget | Optional parent widget for Qt ownership. |
Signals
clicked = Signal(QWidget)
Signal
Emitted when the card receives a mouse press event. The signal carries a reference
to the CardWidget instance itself (as a QWidget type), allowing
the receiving slot to identify which card was clicked. The CardGridView
connects this signal to its select_card() method to implement single-selection
semantics.
Note: The signal type is Signal(QWidget), so the emitted value is the CardWidget instance upcast to QWidget. The receiver may need to cast it back to CardWidget to access card-specific properties like widget or toggle_selection().
Methods
setup_ui(self)
Private
Constructs the card's visual structure. A QGridLayout with 2px margins and
2px spacing is created. Two widgets are placed at position [0,0]:
- background_layer: A
QWidgetwith CSS classcardthat serves as the visual background. Its class changes tocard-selectedwhen selected. - widget: The user-provided widget, placed at the same grid position so it overlaps the background layer.
The mouse press event is redirected by assigning self.mousePressEvent = self._on_click,
which means any default mousePressEvent behavior from QWidget is
completely replaced. This ensures clicks anywhere on the card (including on the
user widget) are captured.
Important: The mousePressEvent is replaced by direct attribute assignment rather than overriding the method. This means if a subclass overrides mousePressEvent after setup_ui() is called, the click handler will be overridden. Also, the original event parameter from the mouse press is not passed to the signal — only the card reference is emitted.
_on_click(self, event)
Private
Click handler that emits the clicked signal with self as the
argument. This method is assigned to self.mousePressEvent in
setup_ui(), replacing the default event handler. The event
parameter (a QMouseEvent) is received but not used — the handler does
not distinguish between left, right, or middle mouse button clicks.
update_widget(self, widget: QWidget)
Public
Replaces the current user widget inside the card with a new one. The old widget
is removed from the layout, detached from its parent (setParent(None) is
implicit via deleteLater()), and scheduled for deletion. The new widget
is stored as self.widget and added to the card's layout at the same
position. The background layer remains untouched — only the user content is swapped.
| Parameter | Type | Description |
|---|---|---|
widget | QWidget | The new widget to display inside the card. |
Tip: This method is useful for updating card content in-place without removing and re-adding the entire card, which would disrupt the grid layout and selection state.
Selection System
toggle_selection(self)
Public
Toggles the card's visual selection state. When toggled to selected, the
background layer's CSS class changes from card to card-selected.
When toggled to deselected, it reverts to card. After changing the
property, the style system is forced to re-evaluate by manually unpolishing
and re-polishing the background layer's style:
self.background_layer.style().unpolish(self.background_layer)
self.background_layer.style().polish(self.background_layer)
This unpolish/re-polish pattern is necessary because Qt's style sheet engine caches property-based selectors. Simply changing a property value does not automatically trigger a style re-evaluation — the engine must be explicitly told to re-apply the style sheet rules to the widget.
Note: The _selected flag is tracked internally but is not exposed as a public property. If external code needs to check whether a card is selected, it would need to inspect the background_layer's CSS class property or add a getter method.
CardGridView
CardGridView
QWidget
A scrollable grid container that manages a collection of CardWidget instances
arranged in a configurable column layout. The class provides a complete CRUD API
for cards (add, update, get, remove, clear), single-selection semantics with visual
feedback, infinite-scroll data loading, and auxiliary UI states (loading indicator,
"Load More" button, empty-state message). Cards are tracked by integer IDs in an
internal dictionary, and the grid position of each card is calculated automatically
based on its insertion order and the current column count.
Constructor
CardGridView.__init__(self, columns: int = 2, parent=None)
Public
Initializes the grid view with a default 2-column layout. The constructor calls
setup_ui() to build the visual structure, then initializes the internal
state variables for card tracking, selection, and infinite-scroll management.
| Parameter | Type | Default | Description |
|---|---|---|---|
columns | int | 2 | The initial number of columns in the grid layout. Must be at least 1. |
parent | QWidget | None | Optional parent widget for Qt ownership. |
Internal State
| Attribute | Type | Default | Description |
|---|---|---|---|
selected_card | Optional[CardWidget] | None | Reference to the currently selected card, or None if no card is selected. |
cards | Dict[int, CardWidget] | {} | Dictionary mapping integer card IDs to their CardWidget instances. |
columns | int | 2 | Current number of grid columns. Affects card positioning calculations. |
has_more | bool | True | Whether more data is available for loading. Controlled by the parent. Set to False when all data has been loaded. |
is_loading | bool | False | Whether a loading operation is currently in progress. Prevents duplicate load requests. |
load_threshold | int | 100 | Distance in pixels from the scroll bottom that triggers the next page load. |
Signals
card_selected = Signal(QWidget)
Signal
Emitted when a card is selected (clicked). The signal carries the inner
widget of the selected card (i.e., card.widget, not the
CardWidget itself). This gives the receiver direct access to the
user-defined content of the selected card, which is typically what application
code needs to react to.
card_removed = Signal(QWidget)
Signal
Emitted when a card is removed from the grid. The signal carries the inner widget of the removed card, allowing the receiver to perform cleanup or update other UI elements in response to the removal.
load_more_requested = Signal()
Signal
Emitted when the infinite-scroll system detects that more data should be loaded
(either because the user scrolled near the bottom or the "Load More" button was
clicked). The parent should connect to this signal, fetch additional data, and
call add_card() for each new item. After adding, the parent should call
hide_loading_indicator() to clear the loading state. If no more data
is available, the parent should set has_more = False.
UI Setup
setup_ui(self)
Private
Constructs the complete visual structure of the grid view. The layout hierarchy is as follows:
- Outer layout: A
QVBoxLayoutwith zero margins fills the entireCardGridViewwidget. - Scroll area: A
QScrollAreawithsetWidgetResizable(True)so the container stretches to fill the available width. Both horizontal and vertical scroll bars are set toScrollBarAsNeeded. - Container widget: A plain
QWidgetwith CSS classsurface-background-layerthat holds the grid layout. This provides the visual background for the card area. - Grid layout: A
QGridLayoutwith 2px spacing and 3px margins where cards are placed.
Three auxiliary UI elements are created but initially hidden:
- loading_label: A
QLabelwith text "Loading more items..." styled in gray, centered, with 20px padding. Shown during infinite-scroll loading. - load_more_button: A
QPushButtonwith text "Load More" and a pointing hand cursor. Provides an optional manual trigger for loading more data. Note: the click connection is commented out in the source. - empty_label: A
QLabelwith text "No results found." styled in 18px gray, centered, with 50px padding. Shown when the grid has no cards.
The scroll area's vertical scrollbar valueChanged signal is connected to
on_scroll_changed() to implement infinite-scroll detection.
Known Issue: The load_more_button.clicked signal connection is commented out in the source code (#self.load_more_button.clicked.connect(self.on_load_more_clicked)). This means clicking the "Load More" button does nothing unless the connection is manually established by the user of this class.
Card CRUD Operations
add_card(self, card_id: int, widget: QWidget) -> CardWidget
Public
Adds a new card to the grid. The card is created by wrapping the provided widget
in a CardWidget, which is then connected to the grid's selection handler
(select_card). The grid position is calculated based on the current
number of cards in the dictionary and the column count: row = count // columns,
col = count % columns. The card is stored in the cards dictionary
by its ID and added to the grid layout at the calculated position.
If a card with the same ID already exists, a ValueError is raised to
prevent duplicate IDs from corrupting the dictionary.
| Parameter | Type | Description |
|---|---|---|
card_id | int | Unique integer identifier for the card. Used for all subsequent CRUD operations. |
widget | QWidget | The user-defined widget to display inside the card. |
CardWidget — The created card widget, allowing the caller to further customize it if needed.update_card(self, card_id: int, widget: QWidget) -> bool
Public
Replaces the inner widget of an existing card with a new widget. The card's position in the grid and its selection state are preserved — only the user content is swapped. This is more efficient than removing and re-adding a card, which would require reorganizing the entire grid.
| Parameter | Type | Description |
|---|---|---|
card_id | int | ID of the card to update. |
widget | QWidget | The new widget to display inside the card. |
True if the card was found and updated; False if the card ID does not exist.get_card(self, card_id: int) -> Optional[QWidget]
Public
Retrieves the inner user widget of a card by its ID. This returns the widget
that was passed to add_card(), not the CardWidget wrapper.
Use this to access or modify the content of a specific card.
| Parameter | Type | Description |
|---|---|---|
card_id | int | ID of the card to retrieve. |
QWidget if the card exists; None otherwise.get_cards(self) -> List[QWidget]
Public
Returns a list of all inner user widgets in the grid, in dictionary insertion order (which, for Python 3.7+, is the order in which cards were added). This is useful for batch operations like iterating over all card contents.
List[QWidget] — List of all card inner widgets.remove_card(self, card_id: int) -> bool
Public
Removes a card from the grid by its ID. The removal process involves several steps:
- If the card is currently selected,
selected_cardis reset toNone. - The card widget is removed from the grid layout and scheduled for deletion via
deleteLater(). - The card is removed from the
cardsdictionary. - All remaining cards are reorganized in the grid via
_reorganize_cards()to fill the gap left by the removed card. - The
card_removedsignal is emitted with the removed card's inner widget. - UI helper elements (loading indicator, load more button, empty message) are hidden.
| Parameter | Type | Description |
|---|---|---|
card_id | int | ID of the card to remove. |
True if the card was found and removed; False if the card ID does not exist.Note: The card_removed signal is emitted after the card is removed from the dictionary and the grid, but the inner widget reference is still valid at the time of emission since deleteLater() only schedules deletion for the next event loop iteration.
Card Selection
select_card(self, card: CardWidget)
Public
Implements single-selection semantics for the grid. When a card is clicked:
- If another card was previously selected, its selection is toggled off (CSS class reverts to
card). - The clicked card's selection is toggled on (CSS class changes to
card-selected). - The
selected_cardreference is updated to the clicked card. - The
card_selectedsignal is emitted with the card's inner widget.
This method is automatically connected to each CardWidget.clicked signal
when the card is added via add_card(). It can also be called programmatically
to select a specific card.
| Parameter | Type | Description |
|---|---|---|
card | CardWidget | The card to select. This is the CardWidget instance, not the inner widget. |
Infinite Scroll & Loading
The infinite-scroll system automatically triggers data loading when the user scrolls
near the bottom of the grid. It consists of three components: a scroll-position watcher,
a loading state manager, and a signal-based data request mechanism. The parent component
controls the availability of more data via the has_more flag.
on_scroll_changed(self, value: int)
Public
Connected to the vertical scrollbar's valueChanged signal. Checks two
guard conditions: if has_more is False (all data loaded)
or is_loading is True (request already in progress), the
method returns immediately. Otherwise, it calculates whether the current scroll
position is within load_threshold pixels of the maximum scroll position.
If so, load_next_page() is called to trigger the next data load.
| Parameter | Type | Description |
|---|---|---|
value | int | The current value of the vertical scrollbar. |
on_load_more_clicked(self)
Public
Handler for the "Load More" button click. Delegates to load_next_page().
Note that the connection to this handler is commented out in the source code, so
this method currently has no effect unless manually connected.
load_next_page(self)
Public
Initiates the next data load cycle. Checks has_more and
is_loading guards, then shows the loading indicator and emits the
load_more_requested signal. The parent component should respond to this
signal by fetching additional data, adding cards via add_card(), and
then calling hide_loading_indicator() to clear the loading state.
show_loading_indicator(self) / hide_loading_indicator(self)
Public
show_loading_indicator() displays the "Loading more items..." label
at the bottom of the grid. If the label has not yet been added to the layout
(parent() is None), it is inserted at the next available row spanning
all columns. The is_loading flag is set to True.
hide_loading_indicator() hides the label and resets
is_loading to False, allowing subsequent scroll-triggered
loads to proceed. This method must be called by the parent after data has been
loaded and added to the grid.
show_load_more_button(self) / hide_load_more_button(self)
Public
show_load_more_button() displays the "Load More" button at the bottom
of the grid. If the button has not yet been added to the layout, it is inserted at
the next available row spanning all columns. This provides a manual fallback for
loading more data when infinite scroll is not desired or not working.
hide_load_more_button() hides the button. Both methods simply toggle
visibility without removing the widget from the layout.
show_empty_message(self, message: str = "No results found.") / hide_empty_message(self)
Public
show_empty_message() displays a centered empty-state message at the
bottom of the grid. The message text is customizable via the message
parameter, defaulting to "No results found." If the label has not yet been added
to the layout, it is inserted at the next available row spanning all columns.
hide_empty_message() hides the empty state label.
| Parameter | Type | Default | Description |
|---|---|---|---|
message | str | "No results found." | Custom empty-state message text. |
State Management
reset(self)
Public
Resets the grid view to its initial state for a new data set. This is typically called before starting a new search or refreshing the entire card list. The method performs the following steps:
- Calls
clear()to remove all cards and reset UI elements. - Sets
has_more = Trueto re-enable infinite scroll. - Sets
is_loading = Falseto clear any stuck loading state. - Scrolls the scrollbar back to the top (value = 0).
clear(self)
Public
Removes all cards and auxiliary UI elements from the grid. The method iterates
through all items in the grid layout, removes each widget from its parent, and
schedules it for deletion via deleteLater(). The cards dictionary
is not explicitly cleared here, but all widget references become invalid after
deletion. The selected_card is reset to None, and all UI
helper elements (loading indicator, load more button, empty message) are hidden.
Known Issue: The clear() method removes widgets from the layout and schedules them for deletion, but it does not clear the self.cards dictionary. This means that after calling clear(), the dictionary still contains stale references to deleted widgets, which could cause issues if add_card() is called with the same IDs (it would raise ValueError for duplicates). The reset() method also does not clear the dictionary. This should be addressed by adding self.cards.clear() to the clear() method.
Column Management
set_columns(self, columns: int)
Public
Changes the number of columns in the grid and reorganizes all existing cards
to fit the new layout. The column count must be at least 1; a ValueError
is raised for invalid values. After updating self.columns,
_reorganize_cards() is called to recalculate the grid positions of
all cards.
| Parameter | Type | Description |
|---|---|---|
columns | int | The new number of columns. Must be ≥ 1. |
_reorganize_cards(self)
Private
Recalculates and reassigns grid positions for all cards based on their order in
the cards dictionary and the current column count. Each card's position
is calculated as row = index // columns, col = index % columns.
The method calls addWidget() for each card, which automatically moves
existing widgets to their new positions within the grid layout.
This method is called after a card is removed (to fill the gap) or after the column count is changed (to reflow the entire grid). The dictionary iteration order determines the visual order of cards in the grid.
Full Method Index
| Class | Method | Visibility | Brief Description |
|---|---|---|---|
| CardWidget | __init__ | Public | Wrap a widget in a styled card frame |
setup_ui | Private | Build layered card UI with click handler | |
_on_click | Private | Emit clicked signal on mouse press | |
update_widget | Public | Replace the inner widget | |
toggle_selection | Public | Toggle visual selection state | |
| CardGridView | __init__ | Public | Initialize grid with configurable columns |
setup_ui | Private | Build scroll area, grid, and auxiliary UI | |
on_scroll_changed | Public | Detect near-bottom scroll for infinite loading | |
on_load_more_clicked | Public | Handle "Load More" button click | |
load_next_page | Public | Show loader and emit load_more_requested | |
show_loading_indicator | Public | Show "Loading more items..." label | |
hide_loading_indicator | Public | Hide loading label, reset is_loading | |
show_load_more_button | Public | Show manual "Load More" button | |
hide_load_more_button | Public | Hide "Load More" button | |
show_empty_message | Public | Show empty-state message | |
hide_empty_message | Public | Hide empty-state message | |
add_card | Public | Add card with unique ID, return CardWidget | |
update_card | Public | Replace inner widget of existing card | |
get_card | Public | Get inner widget by card ID | |
get_cards | Public | Get list of all inner widgets | |
remove_card | Public | Remove card by ID, reorganize grid | |
select_card | Public | Single-selection handler with visual toggle | |
reset | Public | Clear grid, reset scroll and state flags | |
clear | Public | Remove all cards and auxiliary UI | |
set_columns | Public | Change column count and reorganize | |
_reorganize_cards | Private | Recalculate grid positions for all cards |
CSS Class Reference: The following CSS class properties are used by the card system and must be defined in the application's stylesheet: card (default card background), card-selected (selected card background), and surface-background-layer (grid container background). Without these styles, the cards will appear unstyled.