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