Widgets.py PySideAbdhUI — Custom Widget Collection

Widgets.py is a companion module to the PySideAbdhUI framework that provides a collection of reusable custom widgets. Each widget extends a standard PySide6/Qt widget with additional functionality tailored for the framework's UI patterns. The module contains four distinct classes that serve different purposes within the application:

Together, these widgets form the building blocks used by the AbdhWindow class and other framework components. They are designed to be self-contained, styleable through Qt style sheets, and composable within the framework's layout system.

Dependencies

The module imports from both the standard PySide6 library and an internal utility module. Understanding these dependencies is important for comprehending the full capabilities of each widget:

ModuleComponents UsedPurpose
PySide6.QtWidgets QLineEdit, QStackedWidget, QLabel, QWidget, QFrame Base classes that each custom widget inherits from.
PySide6.QtCore Signal, QPropertyAnimation, QRect, QEasingCurve, QParallelAnimationGroup, Qt, Property Signal/slot system, animation framework, geometry types, custom property system for animations.
PySide6.QtGui QAction, QIcon, QPainter, QPen, QColor, QFont Custom painting (RingProgress), action/icon system (SearchBox), color and font handling.
.utils get_icon Internal utility for loading SVG/icon resources by name, used in SearchBox.

StackedWidget

StackedWidget

Inherits QStackedWidget

An animated page-navigation container that extends QStackedWidget with smooth horizontal slide transitions between pages. Instead of the default instant-swap behavior of QStackedWidget, this class animates the current page sliding out while the next page slides in from the opposite direction, using QPropertyAnimation on each widget's geometry property. The direction of the slide (left or right) is determined by whether the target index is higher or lower than the current index.

The widget also provides a full navigation API: go_next, go_back, go_first, go_last, and goto_index. A add_page method handles page insertion with an optional deduplication feature that removes existing pages of the same Python type before adding the new one. An animating flag prevents overlapping animations, ensuring that rapid navigation clicks do not cause visual glitches.

Constructor

StackedWidget.__init__(self) Public

Initializes the stacked widget by calling super().__init__(), then configures animation and visual properties. The CSS class stack is assigned via setProperty('class', 'stack') for external stylesheet targeting. A solid white background is set using setAutoFillBackground(True) with a white palette color to prevent flickering during animated transitions — without this, the transparent background of the parent would be visible between page slides, causing a visual glitch.

AttributeTypeDefaultDescription
animation_durationint400Duration in milliseconds for slide-in/slide-out animations.
animatingboolFalseGuard flag that prevents overlapping animations. Set to True during animation and reset in the completion callback.
target_indexint0The destination index for the current animation. Used by the completion callback to set the final widget index.

Navigation API

add_page(self, page: QWidget, allow_same_tyoes: bool = True) Public

Adds a page widget to the stacked container and automatically navigates to it. The method supports an optional deduplication feature: when allow_same_tyoes is False, it scans all existing pages and removes any widget that has the same Python type as the new page before inserting it. This is useful for ensuring that only one instance of a particular page type exists at any time — for example, preventing multiple "Settings" or "Profile" pages from accumulating in the stack.

When deduplication is active, the scan iterates backwards through the widget list to safely remove items without invalidating indices. Removed widgets are deleted via deleteLater() to ensure proper cleanup. The new page is given setAutoFillBackground(True) to prevent flickering during slide animations.

After adding, go_last() is called to navigate to the newly added page with a slide animation.

ParameterTypeDefaultDescription
pageQWidgetThe page widget to add to the stack.
allow_same_tyoesboolTrueIf False, removes any existing widget of the same type before adding. Note: the parameter name contains a typo ("tyoes" instead of "types").

Known Issue: The parameter name allow_same_tyoes contains a typo — it should be allow_same_types. Additionally, when allow_same_tyoes is False, the code contains a nested loop bug: the outer for i in range(self.count()) loop does nothing because the inner loop immediately shadows the variable i and performs the actual removal. The outer loop's body has no effect.

go_next(self) Public

Navigates to the next page in the stack (current index + 1). If the current page is already the last one, no action is taken — the boundary check new_index < self.count() prevents out-of-bounds navigation. The transition is animated.

go_back(self) Public

Navigates to the previous page in the stack (current index - 1). If the current page is already the first one, no action is taken — the boundary check new_index >= 0 prevents out-of-bounds navigation. The transition is animated.

goto_index(self, index: int) Public

Navigates directly to the page at the specified index with an animated transition. Boundary validation is handled by setCurrentIndexAnimated().

ParameterTypeDescription
indexintThe zero-based index of the target page.
go_last(self) Public

Navigates to the last page in the stack (self.count() - 1). This is called automatically by add_page() after a new page is inserted.

go_first(self) Public

Navigates to the first page in the stack (index 0) with an animated transition.

Animation System

The slide transition system is the core feature of StackedWidget. It replaces the default instant-swap behavior of QStackedWidget with a smooth horizontal slide animation. The system works by manually managing widget geometry through QPropertyAnimation, positioning the incoming page off-screen and animating both pages simultaneously in a QParallelAnimationGroup.

Slide-Right Animation (navigating to a higher index): ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ │ │ Current Widget │ ───► │ (slides left) │ │ │ │ │ └──────────────────────┘ └──────────────────────┘ ┌──────────────────────┐ ┌──────────────────────┐ │ Next Widget │ │ │ │ (starts off-screen │ ───► │ (slides into view) │ │ from the right) │ │ │ └──────────────────────┘ └──────────────────────┘ Direction = +1 (right): Current slides to -width, Next slides from +width to 0 Direction = -1 (left): Current slides to +width, Next slides from -width to 0
setCurrentIndexAnimated(self, index: int) Public

Initiates an animated transition to the page at the specified index. This method performs three guard checks before proceeding:

  1. Index bounds: If index < 0 or index >= self.count(), the call is ignored.
  2. Same index: If the target index equals the current index, no animation is needed.
  3. Animation guard: If self.animating is True, the call is ignored to prevent overlapping animations.

Once the guards pass, the current widget is hidden, the direction is determined (+1 for forward navigation, -1 for backward), and the animation is delegated to setCurrentWidgetAnimated().

ParameterTypeDescription
indexintThe target page index to navigate to.
setCurrentWidgetAnimated(self, next_widget: QWidget, direction: int = -1) Public

Performs the actual animated slide transition between the current widget and the next widget. The method sets up two parallel QPropertyAnimation objects on the geometry property of each widget:

  • Outgoing animation: The current widget starts at QRect(0, 0, width, height) and slides to QRect(-width*direction, 0, width, height). When direction = +1 (forward), it slides left; when direction = -1 (backward), it slides right.
  • Incoming animation: The next widget starts at QRect(width*direction, 0, width, height) (off-screen) and slides to QRect(0, 0, width, height) (center).

Both animations run in a QParallelAnimationGroup with OutCubic easing over animation_duration (400ms). The animating flag is set to True before starting and reset in _on_animation_finished(). The next widget is raised to the top of the Z-order with raise_() so it paints above the outgoing widget during the transition.

ParameterTypeDefaultDescription
next_widgetQWidgetThe widget to animate into view.
directionint-1Slide direction: +1 for forward (right), -1 for backward (left). The default is -1, which means direct calls slide from left by default.

Note: The current widget is hidden with hide() at the start of the animation. This is slightly unusual — the widget is hidden but its geometry is still being animated. Since the animation operates on the widget's geometry property directly, it continues to animate even though the widget is not visible. The visual effect is that only the incoming widget is visible during the transition, which may cause the background to show through.

_on_animation_finished(self) Private

Callback invoked when the parallel animation group finishes. It performs the following cleanup steps:

  1. Set final index: Calls self.setCurrentIndex(self.target_index) to update the internal state to the target page.
  2. Reset animation flag: Sets self.animating = False to allow new navigation operations.
  3. Clean up animation group: Calls self.animation_group.deleteLater() to free the animation objects.
  4. Reset widget geometry: Explicitly sets the current widget's geometry to fill the entire stacked widget area, calls updateGeometry() and adjustSize(), and activates the widget's layout to ensure proper sizing after the animation.

This cleanup is essential because the animation manipulates geometry directly, which can leave widgets in an inconsistent layout state if not properly reset.

Resize Handling

resizeEvent(self, event) Override

Overrides the default resize event to ensure the current page's layout is re-activated when the stacked widget is resized. This is necessary because during an animated transition, widget geometries are managed manually and may not automatically adjust to the new container size. When the animation is not running (not self.animating), the current widget's layout is explicitly activated via layout.activate().

Tip: A commented-out line #current.setGeometry(0, 0, self.width(), self.height()) exists in the source, suggesting that direct geometry setting was previously used but replaced with layout activation for a more flexible approach that respects size policies and stretch factors.

Separator

Separator

Inherits QFrame

A simple visual divider widget used to create horizontal or vertical separator lines in the UI. It extends QFrame and uses the built-in HLine or VLine frame shapes for native rendering, then applies a custom style sheet to control the line's color and thickness. The separator is configured with Plain frame shadow (no 3D effect) for a flat, modern appearance.

Separator.__init__(self, orientation: str = 'horizontal', stroke: int = 1, color: str = '#888888D1', parent=None) Public

Creates a separator line with the specified orientation, stroke width, and color. The orientation determines whether a horizontal (HLine) or vertical (VLine) frame shape is used. The color is applied via a style sheet that sets both color and background-color properties, and constrains the maximum height to the stroke width in pixels for horizontal separators.

ParameterTypeDefaultDescription
orientationstr'horizontal'Line direction. Accepts 'horizontal' (HLine) or any other value (VLine).
strokeint1Line thickness in pixels. Applied as max-height in the stylesheet and as lineWidth.
colorstr'#888888D1'CSS color string for the line. Supports hex with alpha (#RRGGBBAA format). The default is a semi-transparent gray.
parentQWidgetNoneOptional parent widget.

Note: The default color #888888D1 uses 8-digit hex with an alpha channel (D1 = ~82% opacity), giving the separator a subtle, non-intrusive appearance that works well against both light and dark backgrounds.

Label

Label

Inherits QLabel

A signal-emitting extension of QLabel that adds a textChanged signal. Standard QLabel does not emit any signal when its text is changed programmatically, which makes it difficult to implement reactive UI patterns where other widgets need to respond to label text updates. The Label class solves this by overriding setText() to compare the new text with the current text and emit the textChanged signal only when the text actually changes, avoiding unnecessary signal emissions for redundant updates.

Label.__init__(self, text: str = '') Public

Initializes the label with optional text. The text is set via super().setText(text) in the constructor rather than passed to QLabel.__init__() to ensure consistency with the overridden setText() method.

ParameterTypeDefaultDescription
textstr''Initial text content for the label.
textChanged = Signal(str) Signal

A custom PySide6 signal that is emitted whenever the label's text is changed via setText(). The signal carries the new text as a str parameter, allowing connected slots to react to the updated content. The signal is only emitted when the new text differs from the current text, preventing infinite loops in bidirectional data binding scenarios.

setText(self, text: str) Override

Overrides QLabel.setText() to add change detection. The method compares the new text with the current text returned by self.text(). If they differ, it calls super().setText(text) to update the label and then emits the textChanged signal with the new text. If the text is the same, no action is taken, which prevents unnecessary signal emissions and potential infinite loops in signal-slot chains.

ParameterTypeDescription
textstrThe new text to set on the label.

RingProgress

RingProgress

Inherits QWidget

A custom-painted circular progress indicator that renders a ring-shaped arc showing the current progress value as a percentage. Unlike slider or progress bar widgets provided by Qt, RingProgress draws its entire visual representation using QPainter, giving full control over the appearance. The widget consists of three visual layers: a background ring (the track), a colored progress arc, and an optional percentage text label rendered in the center.

All visual properties — ring color, background color, text color, ring width, and font size — are configurable through setter methods. Calling any setter triggers self.update() to schedule a repaint, ensuring the visual state always reflects the current property values.

Constructor

RingProgress.__init__(self, parent=None) Public

Initializes the progress ring with default property values. The minimum widget size is set to 120×120 pixels to ensure the ring is always large enough to be visually meaningful and to prevent layout issues where the widget might be squeezed to zero size.

AttributeTypeDefaultDescription
_valueint0Current progress value, clamped between _min and _max.
_minint0Minimum value in the progress range.
_maxint100Maximum value in the progress range.
_ring_colorQColor#4CAF50Color of the progress arc (Material Design green).
_bg_colorQColor#E0E0E0Color of the background ring track (light gray).
_text_colorQColor#333333Color of the percentage text overlay (dark gray).
_ring_widthint10Width of the ring arc in pixels.
_show_textboolTrueWhether the percentage text is displayed in the center.
_font_sizeint20Point size of the percentage text font.

Methods

setValue(self, val: int) Public

Sets the current progress value. The value is clamped to the valid range [_min, _max] using max(self._min, min(self._max, val)). After setting, self.update() is called to schedule a repaint of the widget.

ParameterTypeDescription
valintThe new progress value. Will be clamped to the current range.
value(self) -> int Public

Returns the current progress value.

Returns: int — The current progress value.
setRange(self, min_val: int, max_val: int) Public

Sets the minimum and maximum values for the progress range. This changes the scale against which the current value is measured. For example, setting setRange(0, 50) means a value of 25 represents 50% progress. After setting, self.update() schedules a repaint.

ParameterTypeDescription
min_valintThe minimum value of the range.
max_valintThe maximum value of the range.
setRingColor(self, color) Public

Sets the color of the progress arc. The input is converted to a QColor object, which accepts CSS color strings ('#FF5722', 'red', 'rgb(255,87,34)') or existing QColor objects.

ParameterTypeDescription
colorstr or QColorThe new color for the progress arc.
setBackgroundColor(self, color) Public

Sets the color of the background ring track that the progress arc fills over. This is typically a muted or semi-transparent color that provides visual context for the unfilled portion of the ring.

ParameterTypeDescription
colorstr or QColorThe new color for the background track.
setRingWidth(self, width: int) Public

Sets the stroke width of the ring arc in pixels. Larger values create a thicker ring, while smaller values create a thinner one. The ring width also affects the inset of the arc from the widget boundary — the arc is drawn inside the widget rectangle with a margin equal to _ring_width on all sides.

ParameterTypeDescription
widthintThe new ring width in pixels.
setShowText(self, visible: bool) Public

Controls whether the percentage text is rendered in the center of the ring. When False, only the arc is drawn, creating a clean, icon-style progress indicator. When True, the current value followed by a percent sign is rendered at the center.

ParameterTypeDescription
visibleboolWhether to show the percentage text overlay.

Custom Painting

paintEvent(self, event) Override

Renders the ring progress indicator using QPainter. The painting process consists of three layers, drawn in order:

1. Background Ring (Track)

A full 360-degree arc is drawn using the _bg_color pen with RoundCap style for smooth endpoints. The arc rectangle is the widget rectangle inset by _ring_width on all sides, ensuring the ring does not overflow the widget boundary. The arc is drawn with drawArc(rect, 0, 360*16) — Qt uses 1/16th of a degree as the unit for arc angles.

2. Progress Arc

The progress fraction is calculated as (_value - _min) / (_max - _min), producing a value between 0.0 and 1.0. This fraction is multiplied by 360×16 to get the span angle in Qt's 1/16-degree units. The progress arc is drawn starting from 90×16 (the 12 o'clock position) with a negative span angle, meaning it progresses clockwise. If _max == _min, the fraction defaults to 0 to prevent division by zero.

3. Percentage Text

If _show_text is True, the current value followed by a percent sign (f"{_value}%") is rendered at the center of the widget using drawText(self.rect(), Qt.AlignCenter, text). The font size is controlled by _font_size.

RingProgress Visual Structure: ┌─────────────────────────┐ │ ╭───────────────╮ │ ← Widget boundary │ │ │ │ │ │ ╭───╮ │ │ ← Background ring (full 360°) │ │ │ 75%│ │ │ ← Percentage text (center) │ │ ╰───╯ │ │ │ │ ▓▓▓▓▓ │ │ ← Progress arc (clockwise from top) │ ╰───────────────╯ │ └─────────────────────────┘ Arc start angle: 90° (12 o'clock position) Arc direction: Clockwise (negative span) Ring inset: _ring_width pixels from each edge Pen cap style: RoundCap (smooth endpoints)

SearchBox

Inherits QLineEdit

An animated search input that collapses to a compact icon when unfocused and smoothly expands to a full-width input field when focused. This pattern is common in modern UIs where search functionality should be accessible but not consume valuable horizontal space when not in use. The animation is implemented using a custom Qt property (expandingWidth) that is animated by QPropertyAnimation, allowing the width change to be smoothly interpolated rather than jumping instantly between states.

The search box features a leading search icon (loaded via get_icon("search")), placeholder text that is only visible when expanded, and a built-in clear button provided by setClearButtonEnabled(True). When the user types text and then clicks away, the box remains expanded — it only collapses if the field is empty, preserving the user's input.

Constructor

SearchBox.__init__(self, parent=None, collapsed_width: int = 32, expanded_width: int = 200, duration: int = 300) Public

Initializes the search box with configurable collapsed and expanded widths, and animation duration. The widget starts in its collapsed state (fixed width = collapsed_width). A QAction with a search icon is added at the leading position, and placeholder text "Search…" is set. A reusable QPropertyAnimation targeting the custom expandingWidth property is created with OutCubic easing for a natural deceleration effect.

ParameterTypeDefaultDescription
parentQWidgetNoneOptional parent widget.
collapsed_widthint32Width in pixels when the search box is collapsed (icon-only). Typically just enough to show the search icon.
expanded_widthint200Width in pixels when the search box is expanded (full input). Large enough for comfortable text entry.
durationint300Duration of the expand/collapse animation in milliseconds.

Custom Property: expandingWidth

expandingWidth = Property(int, getExpandingWidth, setExpandingWidth) Property

A custom Qt property that allows QPropertyAnimation to animate the widget's width. Qt's property animation system requires the target property to be defined as a Property with getter and setter methods. The expandingWidth property wraps the widget's width() and setFixedWidth() calls, making it animatable.

Getter: getExpandingWidth(self) -> int

Returns the current width of the widget via self.width().

Setter: setExpandingWidth(self, width: int)

Sets the widget's fixed width to the given value via self.setFixedWidth(width). Using setFixedWidth ensures the widget does not change size during the animation due to layout constraints.

Focus-Driven Animation

focusInEvent(self, event) Override

When the search box receives focus (clicked or tabbed into), it animates from the collapsed width to the expanded width. The animation uses the reusable _animation object with OutCubic easing, creating a smooth expansion that decelerates as it reaches the target width.

focusOutEvent(self, event) Override

When the search box loses focus, it conditionally collapses back to the collapsed width. The collapse only occurs if the text field is empty (checked via self.text().strip()). If the user has typed text, the search box remains expanded to keep the entered content visible. This is a deliberate UX decision — collapsing with text would hide the user's input and create confusion.

_animate_to(self, target_width: int) Private

Core animation method that drives the width transition. It stops any currently running animation on the reusable _animation object, sets the start value to the current width and the end value to the target width, then starts the animation. Stopping the previous animation before starting a new one prevents conflicts if the user rapidly focuses and unfocuses the widget.

ParameterTypeDescription
target_widthintThe width to animate to (either _collapsed_width or _expanded_width).
SearchBox State Diagram: [Unfocused, Empty] [Focused] ┌──┐ ┌──────────────────────┐ │🔍│ ← collapsed (32px) │🔍 Search… │ ← expanded (200px) └──┘ └──────────────────────┘ │ ↑ │ focusInEvent() │ focusInEvent() │ _animate_to(200) │ _animate_to(200) ↓ │ ┌──────────────────────┐ │ │🔍 Search… │ ────────────┘ └──────────────────────┘ │ │ focusOutEvent() + text is empty │ _animate_to(32) ↓ ┌──┐ │🔍│ ← collapsed again └──┘ If text is NOT empty on focusOut → stays expanded

Full Method Index

ClassMethodVisibilityBrief Description
StackedWidget __init__PublicInitialize with animation config and white background
add_pagePublicAdd page with optional type deduplication
go_nextPublicNavigate to next page (animated)
go_backPublicNavigate to previous page (animated)
goto_indexPublicNavigate to specific index (animated)
go_lastPublicNavigate to last page (animated)
go_firstPublicNavigate to first page (animated)
setCurrentIndexAnimatedPublicInitiate animated transition by index
setCurrentWidgetAnimatedPublicPerform parallel slide animation
_on_animation_finishedPrivateFinalize animation, reset geometry
Separator __init__PublicCreate horizontal/vertical line with custom style
Label __init__PublicInitialize label with optional text
setTextOverrideSet text and emit textChanged signal on change
RingProgress __init__PublicInitialize with default colors and dimensions
setValuePublicSet progress value (clamped to range)
valuePublicGet current progress value
setRangePublicSet min/max range for progress scale
setRingColorPublicSet progress arc color
setBackgroundColorPublicSet background track color
setRingWidthPublicSet ring stroke width in pixels
setShowTextPublicToggle percentage text visibility
paintEventOverrideCustom paint: background ring, progress arc, text
SearchBox __init__PublicInitialize with collapsed/expanded widths
getExpandingWidthPublicGetter for custom property (returns width)
setExpandingWidthPublicSetter for custom property (sets fixed width)
focusInEventOverrideExpand on focus
focusOutEventOverrideCollapse on unfocus (if empty)
_animate_toPrivateAnimate width to target value