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 PyQt6.QtWidgets import QCheckBox, QStyleOptionComboBox, QStyle, QApplication
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,
DoubleSpinBoxAdapter,
PyQtWidgetMeta,
SpinBoxAdapter,
ValueGettable,
ValueSettable,
)
[docs]
class NoScrollSpinBox(SpinBoxAdapter):
"""SpinBox that ignores wheel events to prevent accidental value changes.
Inherits from SpinBoxAdapter which already implements ValueGettable/ValueSettable ABCs.
"""
[docs]
def wheelEvent(self, event: QWheelEvent):
"""Ignore wheel events to prevent accidental value changes."""
event.ignore()
[docs]
class NoScrollDoubleSpinBox(DoubleSpinBoxAdapter):
"""DoubleSpinBox that ignores wheel events to prevent accidental value changes.
Inherits from DoubleSpinBoxAdapter which already implements ValueGettable/ValueSettable ABCs.
"""
[docs]
def wheelEvent(self, event: QWheelEvent):
"""Ignore wheel events to prevent accidental value changes."""
event.ignore()
[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('.')
return text if text else '0'
[docs]
class NoScrollComboBox(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 wheelEvent(self, event: QWheelEvent):
"""Ignore wheel events to prevent accidental value changes."""
event.ignore()
[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(
QCheckBox,
ValueGettable,
ValueSettable,
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.
"""
[docs]
def __init__(self, parent=None):
super().__init__(parent)
self._value_state = CheckboxValueState.CONCRETE
self._cached_placeholder_text = None
# 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 set_placeholder_preview(self, checked: bool) -> None:
"""Display an inherited checkbox value without making it concrete."""
self.setChecked(checked)
self._value_state = CheckboxValueState.PLACEHOLDER
self.setProperty("is_placeholder_state", True)
self._apply_placeholder_palette()
[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.setProperty("is_placeholder_state", False)
self._apply_concrete_palette()
[docs]
def clear_placeholder_cache(self) -> None:
"""Clear cached placeholder text set by the placeholder enhancer."""
self._cached_placeholder_text = None
def _apply_placeholder_palette(self):
"""Apply grey palette to make checkmark dim like placeholder text."""
from PyQt6.QtGui import QPalette
palette = self.palette()
# Set text color to grey - this affects the checkmark color
palette.setColor(QPalette.ColorRole.Text, QColor(136, 136, 136)) # Grey like placeholder text
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()
palette.setColor(QPalette.ColorRole.Text, app_palette.color(QPalette.ColorRole.Text))
self.setPalette(palette)
[docs]
def mousePressEvent(self, event):
"""On click, switch from placeholder to explicit value."""
if self.is_placeholder():
self._value_state = CheckboxValueState.CONCRETE
# Clear placeholder property so get_value returns actual boolean
self.setProperty("is_placeholder_state", False)
# Restore normal palette
self._apply_concrete_palette()
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
# Placeholder: Draw with grey text color to dim the checkmark
from PyQt6.QtWidgets import QStyle, QStyleOptionButton
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get the checkbox style option
option = QStyleOptionButton()
self.initStyleOption(option)
# Set grey text color - this affects the checkmark color
option.palette.setColor(option.palette.ColorRole.Text, QColor(136, 136, 136))
# Draw the checkbox with the modified palette
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