Source code for pyqt_reactive.widgets.no_scroll_spinbox
"""
No-scroll spinbox widgets for PyQt6.
Prevents accidental value changes from mouse wheel events.
"""
from enum import Enum
from typing import Callable
from PyQt6.QtWidgets import (
QApplication,
QCheckBox,
QStyle,
QStyleOptionButton,
QStyleOptionComboBox,
)
from PyQt6.QtGui import QWheelEvent, QFont, QColor, QPainter
from PyQt6.QtCore import Qt
# Import adapters that already implement ValueGettable/ValueSettable
from pyqt_reactive.protocols import (
ComboBoxAdapter,
ChangeSignalEmitter,
DoubleSpinBoxAdapter,
PlaceholderStateMixin,
PyQtWidgetMeta,
ResolvedValuePreviewSettable,
SpinBoxAdapter,
ValueGettable,
ValueSettable,
)
[docs]
class IgnoreWheelEventMixin:
"""Shared wheel-event policy for widgets that should not scroll values."""
[docs]
class NoScrollSpinBox(IgnoreWheelEventMixin, SpinBoxAdapter):
"""SpinBox that ignores wheel events to prevent accidental value changes.
Inherits from SpinBoxAdapter which already implements ValueGettable/ValueSettable ABCs.
"""
[docs]
class NoScrollDoubleSpinBox(IgnoreWheelEventMixin, DoubleSpinBoxAdapter):
"""DoubleSpinBox that ignores wheel events to prevent accidental value changes.
Inherits from DoubleSpinBoxAdapter which already implements ValueGettable/ValueSettable ABCs.
"""
[docs]
def textFromValue(self, value: float) -> str:
"""Convert value to string without trailing zeros for clean display.
Users can still type additional digits when editing - this only affects
the display format, not the underlying precision.
Examples:
1.5 -> "1.5"
1.0 -> "1"
1.567 -> "1.567"
0.0001 -> "0.0001"
"""
# Format with all available precision first
text = super().textFromValue(value)
# Remove trailing zeros after decimal point
if '.' in text:
text = text.rstrip('0').rstrip('.')
if text:
return text
return '0'
[docs]
class NoScrollComboBox(IgnoreWheelEventMixin, ComboBoxAdapter):
"""ComboBox that ignores wheel events to prevent accidental value changes.
Inherits from ComboBoxAdapter which already implements ValueGettable/ValueSettable ABCs.
Supports placeholder text when currentIndex == -1 (for None values).
"""
[docs]
def __init__(self, parent=None, placeholder=""):
super().__init__(parent)
self._placeholder = placeholder
self._placeholder_active = True
[docs]
def setPlaceholder(self, text: str):
"""Set the placeholder text shown when currentIndex == -1."""
self._placeholder = text
self.update()
[docs]
def setCurrentIndex(self, index: int):
"""Override to track when placeholder should be active."""
super().setCurrentIndex(index)
self._placeholder_active = (index == -1)
self.update()
def get_value(self):
"""Implement ValueGettable ABC."""
if self.currentIndex() < 0:
return None
return self.itemData(self.currentIndex())
def set_value(self, value):
"""Implement ValueSettable ABC."""
# Find index of item with matching data
for i in range(self.count()):
if self.itemData(i) == value:
self.setCurrentIndex(i)
return
# Value not found - clear selection
self.setCurrentIndex(-1)
[docs]
def get_value(self):
"""Get current value (item data at current index)."""
if self.currentIndex() < 0:
return None
return self.itemData(self.currentIndex())
[docs]
def set_value(self, value):
"""Set current value by finding matching item data."""
if value is None:
self.setCurrentIndex(-1)
else:
for i in range(self.count()):
if self.itemData(i) == value:
self.setCurrentIndex(i)
return
# Value not found - clear selection
self.setCurrentIndex(-1)
[docs]
def paintEvent(self, event):
"""Override to draw placeholder text when currentIndex == -1."""
if self._placeholder_active and self.currentIndex() == -1 and self._placeholder:
# Use regular QPainter to have full control over text rendering
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw the combobox frame using style
option = QStyleOptionComboBox()
self.initStyleOption(option)
option.currentText = "" # Don't let style draw the text
self.style().drawComplexControl(QStyle.ComplexControl.CC_ComboBox, option, painter, self)
# Now manually draw the placeholder text with our styling
placeholder_color = QColor("#888888")
font = QFont(self.font())
font.setItalic(True)
painter.setPen(placeholder_color)
painter.setFont(font)
# Get the text rect from the style
text_rect = self.style().subControlRect(
QStyle.ComplexControl.CC_ComboBox,
option,
QStyle.SubControl.SC_ComboBoxEditField,
self
)
# Draw the placeholder text
painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._placeholder)
painter.end()
else:
super().paintEvent(event)
[docs]
class CheckboxValueState(Enum):
"""Value ownership state for None-aware checkboxes."""
PLACEHOLDER = "placeholder"
CONCRETE = "concrete"
[docs]
class NoneAwareCheckBox(
PlaceholderStateMixin,
QCheckBox,
ValueGettable,
ValueSettable,
ResolvedValuePreviewSettable,
ChangeSignalEmitter,
metaclass=PyQtWidgetMeta,
):
"""
QCheckBox that supports None state for lazy dataclass contexts.
Shows inherited value as grayed placeholder when value is None.
Clicking converts placeholder to explicit value.
"""
PLACEHOLDER_PAINT_OPACITY = 0.45
[docs]
def __init__(self, parent=None):
super().__init__(parent)
self._value_state = CheckboxValueState.CONCRETE
# Prevent horizontal stretching - checkbox should only be as wide as its content
from PyQt6.QtWidgets import QSizePolicy
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
[docs]
def get_value(self):
"""Get value, returning None if in placeholder state."""
if self.is_placeholder():
return None
return self.isChecked()
[docs]
def is_placeholder(self) -> bool:
"""Return whether the checkbox is currently displaying inherited state."""
return self._value_state is CheckboxValueState.PLACEHOLDER
[docs]
def set_value(self, value):
"""Set value, handling None by leaving in placeholder state."""
if value is None:
# Don't change state - placeholder system will set the preview value
self._value_state = CheckboxValueState.PLACEHOLDER
# Set grey palette for placeholder checkmark
self._apply_placeholder_palette()
else:
self._value_state = CheckboxValueState.CONCRETE
self.setChecked(bool(value))
# Restore normal palette
self._apply_concrete_palette()
[docs]
def connect_change_signal(self, callback: Callable[[bool | None], None]) -> None:
"""Implement ChangeSignalEmitter ABC."""
def emit_concrete_value() -> None:
self.convert_placeholder_to_concrete()
callback(self.get_value())
self.stateChanged.connect(emit_concrete_value)
[docs]
def disconnect_change_signal(self, callback: Callable[[bool | None], None]) -> None:
"""Implement ChangeSignalEmitter ABC."""
try:
self.stateChanged.disconnect(callback)
except TypeError:
pass
[docs]
def set_placeholder_preview(self, checked: bool) -> None:
"""Display an inherited checkbox value without making it concrete."""
signals_blocked = self.blockSignals(True)
try:
self.setChecked(checked)
self._value_state = CheckboxValueState.PLACEHOLDER
self.mark_placeholder_state()
self._apply_placeholder_palette()
finally:
self.blockSignals(signals_blocked)
[docs]
def set_resolved_value_preview(self, value) -> None:
"""Display an inherited resolved bool while preserving raw None."""
if value is None:
self.set_placeholder_preview(False)
return
if not isinstance(value, bool):
raise TypeError(
"NoneAwareCheckBox resolved previews require bool or None values, "
f"got {type(value).__name__}."
)
self.set_placeholder_preview(value)
[docs]
def convert_placeholder_to_concrete(self) -> None:
"""Keep the displayed value but mark it as a user-controlled value."""
if not self.is_placeholder():
return
self._value_state = CheckboxValueState.CONCRETE
self.clear_placeholder_state()
self._apply_concrete_palette()
[docs]
def toggle(self) -> None:
"""Toggle as a user action, committing placeholder state first."""
self.convert_placeholder_to_concrete()
super().toggle()
def _apply_placeholder_palette(self):
"""Apply grey palette to make checkmark dim like placeholder text."""
from PyQt6.QtGui import QPalette
palette = self.palette()
placeholder_color = QColor(136, 136, 136)
for role in (
QPalette.ColorRole.Text,
QPalette.ColorRole.WindowText,
QPalette.ColorRole.ButtonText,
):
palette.setColor(role, placeholder_color)
self.setPalette(palette)
def _apply_concrete_palette(self):
"""Restore normal palette for concrete values."""
from PyQt6.QtGui import QPalette
# Use application palette to get the proper text color for the theme
app_palette = QApplication.palette()
palette = self.palette()
for role in (
QPalette.ColorRole.Text,
QPalette.ColorRole.WindowText,
QPalette.ColorRole.ButtonText,
):
palette.setColor(role, app_palette.color(role))
self.setPalette(palette)
[docs]
def mousePressEvent(self, event):
"""On click, switch from placeholder to explicit value."""
self.convert_placeholder_to_concrete()
super().mousePressEvent(event)
[docs]
def paintEvent(self, event):
"""Draw with placeholder styling.
For placeholder state, draw the checkbox with grey text color
to make the checkmark appear dimmed.
"""
if not self.is_placeholder():
# Concrete value: Normal styling
super().paintEvent(event)
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setOpacity(self.PLACEHOLDER_PAINT_OPACITY)
option = QStyleOptionButton()
self.initStyleOption(option)
for role in (
option.palette.ColorRole.Text,
option.palette.ColorRole.WindowText,
option.palette.ColorRole.ButtonText,
):
option.palette.setColor(role, QColor(136, 136, 136))
self.style().drawControl(QStyle.ControlElement.CE_CheckBox, option, painter, self)
painter.end()
# NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox inherit from adapters
# which are already registered, so no additional registration needed