Field Styling and Visual Indicators Architecture
Centralized documentation for `*` and `_` visual indicators, provenance navigation, and reset button styling.
This document provides the single source of truth for how pyqt-reactive displays visual indicators on form fields, reset buttons, and provenance navigation elements.
Overview
Parameter forms use visual indicators to communicate field state to users:
Asterisk (*) - Field has unsaved changes (resolved value differs from saved)
Underline (_) - Field has explicit value (differs from signature default)
Caret (^) - Provenance navigation available (field inherits from ancestor)
“Reset” button - Can reset field to default, with * and _ indicators showing state
Visual Semantics
Two independent boolean indicators computed per field:
Indicator |
Source |
Meaning |
|---|---|---|
|
|
Resolved value differs from saved resolved value. Unsaved changes exist. |
|
|
Raw value differs from signature default. User has explicitly set a value. |
These indicators are orthogonal:
A field can be dirty (
*) without differing from signature (e.g., edited then reverted)A field can differ from signature (
_) without being dirty (e.g., saved explicit value)Both can be present (
*_) or neither
Computing Indicators
from pyqt_reactive.utils.styling_utils import get_field_indicators
# Get both indicators for a field
has_star, has_underline = get_field_indicators(
state=manager.state,
field_id=manager.field_id, # e.g., "fiji_streaming_config"
param_name="enabled" # e.g., "enabled"
)
# Returns: (bool, bool)
The dotted_path is constructed as:
dotted_path = f'{field_id}.{param_name}' if field_id else param_name
# "fiji_streaming_config.enabled" or just "enabled"
Where Visual Indicators Appear
Field Labels
Implementation: LabelWithHelp.set_dirty_indicator() and set_underline()
*prefix on label textFont underline for
_Updated via _update_label_styling() in parameter_form_manager.py
Update Triggers
When Field Value Changes (Normal Edit)
Flow: Widget change → FieldChangeDispatcher.dispatch()
User edits widget value
Widget emits change signal
FieldChangeEvent created and dispatched
state.update_parameter() called
Updates triggered: - Sibling placeholder refresh - Enabled field styling (if applicable) - Reset button styling (all reset buttons in manager) - Provenance button visibility (if in groupbox title)
Local signal emission: parameter_changed
Code:
# Update reset button styling for ALL reset buttons in this manager
from pyqt_reactive.utils.styling_utils import update_reset_button_styling
for field_name, reset_button in source.reset_buttons.items():
update_reset_button_styling(reset_button, source.state, source.field_id, field_name)
When Field is Reset (Individual)
Flow: reset_parameter() → _update_label_styling() → _update_reset_button_styling()
User clicks reset button
reset_parameter(param_name) called
state.update_parameter() with None/reset value
FieldChangeDispatcher.dispatch() called
_update_label_styling(param_name) called (updates label)
_update_reset_button_styling(param_name) called (updates reset button)
_update_provenance_button_visibility() called (updates provenance button)
Why separate updates? The dispatcher runs for ALL field changes, but individual reset needs immediate visual feedback. The styling updates ensure consistency even if dispatcher has slight delay.
Code:
def reset_parameter(self, param_name: str) -> None:
# ... reset logic ...
event = FieldChangeEvent(param_name, reset_value, self, is_reset=True)
FieldChangeDispatcher.instance().dispatch(event)
# Update label styling after reset
self._update_label_styling(param_name)
# Update reset button styling
self._update_reset_button_styling(param_name)
# Update provenance button visibility
self._update_provenance_button_visibility()
When All Fields Reset (Reset All)
Flow: reset_all_parameters() → batch reset → final styling update
User clicks “Reset All” button
reset_all_parameters() called
Loop: calls reset_parameter() for each parameter
Single placeholder refresh at end (optimization)
Loop: Update all reset button styling
Single provenance button update
Optimization: Reset button and provenance updates happen ONCE at the end instead of per-parameter, since reset_parameter() already updates during the loop.
Code:
def reset_all_parameters(self) -> None:
# ... batch reset logic ...
# Update all reset buttons and provenance button once at the end
for param_name in param_names:
self._update_reset_button_styling(param_name)
self._update_provenance_button_visibility()
Architecture Rationale
Why Two Update Paths?
FieldChangeDispatcher: Catches ALL changes including user edits, sibling inheritance updates, cross-window changes
reset_parameter(): Ensures immediate visual feedback for reset operations, handles batch reset optimization
Why Font Underline Instead of CSS?
CSS text-decoration: underline via setStyleSheet() replaces all existing button styles. Using setFont() with underline:
Preserves hover/pressed styling from
_apply_reset_button_style()Only modifies the underline attribute
Works across all button states