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

ModuleComponents UsedPurpose
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:

Widget Hierarchy: CardGridView (QWidget) └── QVBoxLayout └── QScrollArea (self.scroll_area) └── Container QWidget (class: surface-background-layer) └── QGridLayout (self.grid_layout) ├── CardWidget [0,0] ──┐ ├── CardWidget [0,1] ──┤ Each CardWidget contains: ├── CardWidget [1,0] ──┤ ┌────────────────────┐ ├── CardWidget [1,1] ──┤ │ QGridLayout │ ├── ... │ │ ┌────────────────┐ │ ├── loading_label │ │ │ background_layer│ │ ├── load_more_button │ │ │ (class: card) │ │ └── empty_label │ │ ├────────────────┤ │ │ │ │ user widget │ │ │ │ │ (overlaid) │ │ │ │ └────────────────┘ │ │ └────────────────────┘ └──────────────────────────┘ Card Position Calculation: row = card_index // columns col = card_index % columns

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

Inherits 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.

ParameterTypeDescription
widgetQWidgetThe user-defined widget to display inside the card. This can be any QWidget subclass — a form, an image viewer, a custom layout, etc.
parentQWidgetOptional 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]:

  1. background_layer: A QWidget with CSS class card that serves as the visual background. Its class changes to card-selected when selected.
  2. 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.

ParameterTypeDescription
widgetQWidgetThe 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

Inherits 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.

ParameterTypeDefaultDescription
columnsint2The initial number of columns in the grid layout. Must be at least 1.
parentQWidgetNoneOptional parent widget for Qt ownership.

Internal State

AttributeTypeDefaultDescription
selected_cardOptional[CardWidget]NoneReference to the currently selected card, or None if no card is selected.
cardsDict[int, CardWidget]{}Dictionary mapping integer card IDs to their CardWidget instances.
columnsint2Current number of grid columns. Affects card positioning calculations.
has_moreboolTrueWhether more data is available for loading. Controlled by the parent. Set to False when all data has been loaded.
is_loadingboolFalseWhether a loading operation is currently in progress. Prevents duplicate load requests.
load_thresholdint100Distance 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:

  1. Outer layout: A QVBoxLayout with zero margins fills the entire CardGridView widget.
  2. Scroll area: A QScrollArea with setWidgetResizable(True) so the container stretches to fill the available width. Both horizontal and vertical scroll bars are set to ScrollBarAsNeeded.
  3. Container widget: A plain QWidget with CSS class surface-background-layer that holds the grid layout. This provides the visual background for the card area.
  4. Grid layout: A QGridLayout with 2px spacing and 3px margins where cards are placed.

Three auxiliary UI elements are created but initially hidden:

  • loading_label: A QLabel with text "Loading more items..." styled in gray, centered, with 20px padding. Shown during infinite-scroll loading.
  • load_more_button: A QPushButton with 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 QLabel with 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.

ParameterTypeDescription
card_idintUnique integer identifier for the card. Used for all subsequent CRUD operations.
widgetQWidgetThe user-defined widget to display inside the card.
Returns: 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.

ParameterTypeDescription
card_idintID of the card to update.
widgetQWidgetThe new widget to display inside the card.
Returns: 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.

ParameterTypeDescription
card_idintID of the card to retrieve.
Returns: The inner 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.

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

  1. If the card is currently selected, selected_card is reset to None.
  2. The card widget is removed from the grid layout and scheduled for deletion via deleteLater().
  3. The card is removed from the cards dictionary.
  4. All remaining cards are reorganized in the grid via _reorganize_cards() to fill the gap left by the removed card.
  5. The card_removed signal is emitted with the removed card's inner widget.
  6. UI helper elements (loading indicator, load more button, empty message) are hidden.
ParameterTypeDescription
card_idintID of the card to remove.
Returns: 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:

  1. If another card was previously selected, its selection is toggled off (CSS class reverts to card).
  2. The clicked card's selection is toggled on (CSS class changes to card-selected).
  3. The selected_card reference is updated to the clicked card.
  4. The card_selected signal 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.

ParameterTypeDescription
cardCardWidgetThe 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.

Infinite Scroll Flow: ┌─────────────────────────────────────────┐ │ CardGridView │ │ │ │ User scrolls down │ │ │ │ │ ▼ │ │ on_scroll_changed(value) │ │ │ │ │ ├── has_more? ──── No ──→ return │ │ ├── is_loading? ── Yes ─→ return │ │ │ │ │ ▼ │ │ scrollbar.max - value <= threshold? │ │ │ │ │ ├── No ──→ return │ │ │ │ │ ▼ │ │ load_next_page() │ │ │ │ │ ├── show_loading_indicator() │ │ └── emit load_more_requested │ │ │ │ ═════════════════════════════════════ │ │ │ │ Parent receives signal │ │ │ │ │ ├── Fetches data │ │ ├── Calls add_card() for each │ │ ├── Calls hide_loading_indicator()│ │ └── Sets has_more = False if done │ └─────────────────────────────────────────┘
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.

ParameterTypeDescription
valueintThe 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.

ParameterTypeDefaultDescription
messagestr"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:

  1. Calls clear() to remove all cards and reset UI elements.
  2. Sets has_more = True to re-enable infinite scroll.
  3. Sets is_loading = False to clear any stuck loading state.
  4. 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.

ParameterTypeDescription
columnsintThe 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.

Reorganization Examples: 2 Columns → 3 Columns: ┌────┬────┐ ┌────┬────┬────┐ │ 0 │ 1 │ │ 0 │ 1 │ 2 │ ├────┼────┤ → ├────┼────┼────┤ │ 2 │ 3 │ │ 3 │ 4 │ 5 │ ├────┼────┤ └────┴────┴────┘ │ 4 │ 5 │ └────┴────┘ After Removing Card #2: ┌────┬────┐ ┌────┬────┐ │ 0 │ 1 │ │ 0 │ 1 │ ├────┼────┤ → ├────┼────┤ │ XX │ 3 │ │ 3 │ 4 │ ├────┼────┤ ├────┼────┤ │ 4 │ 5 │ │ 5 │ │ └────┴────┘ └────┴────┘

Full Method Index

ClassMethodVisibilityBrief Description
CardWidget __init__PublicWrap a widget in a styled card frame
setup_uiPrivateBuild layered card UI with click handler
_on_clickPrivateEmit clicked signal on mouse press
update_widgetPublicReplace the inner widget
toggle_selectionPublicToggle visual selection state
CardGridView __init__PublicInitialize grid with configurable columns
setup_uiPrivateBuild scroll area, grid, and auxiliary UI
on_scroll_changedPublicDetect near-bottom scroll for infinite loading
on_load_more_clickedPublicHandle "Load More" button click
load_next_pagePublicShow loader and emit load_more_requested
show_loading_indicatorPublicShow "Loading more items..." label
hide_loading_indicatorPublicHide loading label, reset is_loading
show_load_more_buttonPublicShow manual "Load More" button
hide_load_more_buttonPublicHide "Load More" button
show_empty_messagePublicShow empty-state message
hide_empty_messagePublicHide empty-state message
add_cardPublicAdd card with unique ID, return CardWidget
update_cardPublicReplace inner widget of existing card
get_cardPublicGet inner widget by card ID
get_cardsPublicGet list of all inner widgets
remove_cardPublicRemove card by ID, reorganize grid
select_cardPublicSingle-selection handler with visual toggle
resetPublicClear grid, reset scroll and state flags
clearPublicRemove all cards and auxiliary UI
set_columnsPublicChange column count and reorganize
_reorganize_cardsPrivateRecalculate 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.