Skip to content

Commit 0f2a345

Browse files
authored
fix: Use an event filter to avoid scrolling (#433)
* Use an event filter to avoid scrolling * Delete FIXME Was already fixed * Use NoWheelTableWidget instead Thanks @tlambert03 for the suggestion/starter code * Use the NoWheelTableWidget for Device Properties * Update tests * Don't pass tuple to QObject.findChildren()
1 parent 967fa6a commit 0f2a345

File tree

6 files changed

+130
-48
lines changed

6 files changed

+130
-48
lines changed

src/pymmcore_widgets/_util.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@
22

33
import re
44
from pathlib import Path
5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING
66

77
import useq
88
from psygnal import SignalInstance
99
from pymmcore_plus import CMMCorePlus
10-
from qtpy.QtCore import QMarginsF, QObject, Qt
11-
from qtpy.QtGui import QPainter, QPaintEvent, QPen, QResizeEvent
10+
from qtpy.QtCore import QEvent, QMarginsF, QObject, Qt
11+
from qtpy.QtGui import QPainter, QPaintEvent, QPen, QResizeEvent, QWheelEvent
1212
from qtpy.QtWidgets import (
13+
QAbstractSlider,
14+
QAbstractSpinBox,
1315
QComboBox,
1416
QDialog,
1517
QDialogButtonBox,
1618
QGraphicsScene,
1719
QGraphicsView,
1820
QLabel,
21+
QScrollBar,
22+
QTableWidget,
1923
QVBoxLayout,
2024
QWidget,
2125
)
@@ -24,6 +28,42 @@
2428
if TYPE_CHECKING:
2529
from collections.abc import Sequence
2630
from contextlib import AbstractContextManager
31+
from typing import Any
32+
33+
34+
class NoWheelTableWidget(QTableWidget):
35+
"""QTableWidget that prevents scrolling behavior of child widgets."""
36+
37+
def __init__(self, *args: Any, **kwargs: Any) -> None:
38+
super().__init__(*args, **kwargs)
39+
if view := self.viewport():
40+
view.installEventFilter(self)
41+
42+
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None:
43+
if widget is not None:
44+
# Note that PySide does not allow a tuple as an arg to findChildren
45+
for widget_type in (QAbstractSpinBox, QAbstractSlider, QComboBox):
46+
for child in widget.findChildren(widget_type):
47+
child.installEventFilter(self)
48+
widget.installEventFilter(self)
49+
super().setCellWidget(row, column, widget)
50+
51+
def eventFilter(self, object: QObject | None, event: QEvent | None) -> bool:
52+
# Many "value widgets" (e.g. Combo boxes, sliders) use scroll events to
53+
# change their value. The purpose of this widget is to prevent that.
54+
# Note that if that widget provides a scrollbar (e.g. the ListView
55+
# dropdown of a Combo Box), scroll events going to that scrollbar should
56+
# be let through.
57+
if isinstance(event, QWheelEvent) and not isinstance(object, QScrollBar):
58+
# Scroll the vertical scrollbar manually, to avoid recursion error.
59+
if sb := self.verticalScrollBar():
60+
delta = event.angleDelta().y()
61+
sb.setValue(sb.value() - delta)
62+
# Consume the event so child widgets don't process it
63+
return True
64+
65+
# otherwise process normally
66+
return False
2767

2868

2969
class ComboMessageBox(QDialog):

src/pymmcore_widgets/config_presets/_group_preset_widget/_group_preset_table_widget.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
QWidget,
2121
)
2222

23-
from pymmcore_widgets._util import block_core, load_system_config
23+
from pymmcore_widgets._util import NoWheelTableWidget, block_core, load_system_config
2424
from pymmcore_widgets.control._presets_widget import PresetsWidget
2525
from pymmcore_widgets.device_properties._property_widget import PropertyWidget
2626

@@ -32,18 +32,18 @@
3232
UNNAMED_PRESET = "NewPreset"
3333

3434

35-
class _MainTable(QTableWidget):
35+
class _MainTable(NoWheelTableWidget):
3636
"""Set table properties for Group and Preset TableWidget."""
3737

3838
def __init__(self) -> None:
3939
super().__init__()
40-
hdr = self.horizontalHeader()
41-
hdr.setStretchLastSection(True)
42-
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter)
43-
vh = self.verticalHeader()
44-
vh.setVisible(False)
45-
vh.setSectionResizeMode(vh.ResizeMode.Fixed)
46-
vh.setDefaultSectionSize(24)
40+
if (hdr := self.horizontalHeader()) is not None:
41+
hdr.setStretchLastSection(True)
42+
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter)
43+
if (vh := self.verticalHeader()) is not None:
44+
vh.setVisible(False)
45+
vh.setSectionResizeMode(vh.ResizeMode.Fixed)
46+
vh.setDefaultSectionSize(24)
4747
self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
4848
self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
4949
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)

src/pymmcore_widgets/control/_presets_widget.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,13 @@
44

55
from pymmcore_plus import CMMCorePlus, DeviceType
66
from qtpy.QtCore import Qt
7-
from qtpy.QtGui import QBrush, QWheelEvent
7+
from qtpy.QtGui import QBrush
88
from qtpy.QtWidgets import QComboBox, QHBoxLayout, QWidget
99
from superqt.utils import signals_blocked
1010

1111
from pymmcore_widgets._util import block_core
1212

1313

14-
class _PresetComboBox(QComboBox):
15-
"""A QComboBox tailored to selecting group presets."""
16-
17-
def wheelEvent(self, e: QWheelEvent | None) -> None:
18-
# Scrolling over the combobox can easily lead to accidents, e.g. switching the
19-
# objective lens.
20-
pass
21-
22-
2314
class PresetsWidget(QWidget):
2415
"""A Widget to create a QCombobox containing the presets of the specified group.
2516
@@ -61,7 +52,7 @@ def __init__(
6152
# since they must be all the same
6253
self.dev_prop = self._get_preset_dev_prop(self._group, self._presets[0])
6354

64-
self._combo = _PresetComboBox()
55+
self._combo = QComboBox()
6556
self._combo.currentTextChanged.connect(self._update_tooltip)
6657
self._combo.addItems(self._presets)
6758
self._combo.setCurrentText(self._mmc.getCurrentConfig(self._group))

src/pymmcore_widgets/device_properties/_device_property_table.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from superqt.fonticon import icon
1111

1212
from pymmcore_widgets._icons import ICONS
13+
from pymmcore_widgets._util import NoWheelTableWidget
1314

1415
from ._property_widget import PropertyWidget
1516

@@ -19,7 +20,7 @@
1920
logger = getLogger(__name__)
2021

2122

22-
class DevicePropertyTable(QTableWidget):
23+
class DevicePropertyTable(NoWheelTableWidget):
2324
"""Table of all currently loaded device properties.
2425
2526
This table is used by `PropertyBrowser` to display all properties in the system,

tests/test_presets_widget.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
from typing import TYPE_CHECKING
44

55
import pytest
6-
from qtpy.QtCore import QPoint, QPointF, Qt
7-
from qtpy.QtGui import QWheelEvent
86

97
from pymmcore_widgets.control._presets_widget import PresetsWidget
108

@@ -81,25 +79,3 @@ def test_preset_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
8179

8280
global_mmcore.deleteConfigGroup("Camera")
8381
assert "Camera" not in global_mmcore.getAvailableConfigGroups()
84-
85-
86-
def test_preset_widget_no_scroll(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
87-
# Test that the widget does not respond to scroll events when unfocused.
88-
# It is easy to accidentally scroll the widget when trying to scroll the group
89-
# preset window, which could lead to costly accidents (e.g. switching objectives)
90-
wdg = PresetsWidget("Camera")
91-
qtbot.addWidget(wdg)
92-
93-
previous = wdg._combo.currentText()
94-
e = QWheelEvent(
95-
QPointF(0, 0), # pos
96-
QPointF(0, 0), # globalPos
97-
QPoint(0, 0), # pixelDelta
98-
QPoint(0, -120), # angleDelta
99-
Qt.MouseButton.NoButton, # buttons
100-
Qt.KeyboardModifier.NoModifier, # modifiers
101-
Qt.ScrollPhase.NoScrollPhase, # phase
102-
False, # inverted
103-
)
104-
wdg._combo.wheelEvent(e)
105-
assert wdg._combo.currentText() == previous

tests/test_utils.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from qtpy.QtCore import QCoreApplication, QPoint, QPointF, Qt
6+
from qtpy.QtGui import QWheelEvent
7+
from qtpy.QtWidgets import QApplication, QComboBox
8+
from superqt import QDoubleSlider
9+
10+
from pymmcore_widgets._util import NoWheelTableWidget
11+
12+
if TYPE_CHECKING:
13+
from pymmcore_plus import CMMCorePlus
14+
from pytestqt.qtbot import QtBot
15+
16+
17+
WHEEL_UP = QWheelEvent(
18+
QPointF(0, 0), # pos
19+
QPointF(0, 0), # globalPos
20+
QPoint(0, 0), # pixelDelta
21+
QPoint(0, 120), # angleDelta
22+
Qt.MouseButton.NoButton, # buttons
23+
Qt.KeyboardModifier.NoModifier, # modifiers
24+
Qt.ScrollPhase.NoScrollPhase, # phase
25+
False, # inverted
26+
)
27+
28+
29+
def test_no_wheel_table_scroll(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
30+
tbl = NoWheelTableWidget()
31+
qtbot.addWidget(tbl)
32+
tbl.show()
33+
34+
# Create enough widgets to scroll
35+
sb = tbl.verticalScrollBar()
36+
assert sb is not None
37+
while sb.maximum() == 0:
38+
new_row = tbl.rowCount()
39+
tbl.insertRow(new_row)
40+
tbl.setCellWidget(new_row, 0, QComboBox(tbl))
41+
QApplication.processEvents()
42+
43+
# Test Combo Box
44+
combo = QComboBox(tbl)
45+
combo.addItems(["combo0", "combo1", "combo2"])
46+
combo.setCurrentIndex(1)
47+
combo_row = tbl.rowCount()
48+
tbl.insertRow(combo_row)
49+
tbl.setCellWidget(combo_row, 0, combo)
50+
51+
sb.setValue(sb.maximum())
52+
# Synchronous event emission and allows us to pass through the event filter
53+
QCoreApplication.sendEvent(combo, WHEEL_UP)
54+
# Assert the table widget scrolled but the combo didn't change
55+
assert sb.value() < sb.maximum()
56+
assert combo.currentIndex() == 1
57+
58+
# Test Slider
59+
slider = QDoubleSlider(tbl)
60+
slider.setRange(0, 1)
61+
slider.setValue(0)
62+
slider_row = tbl.rowCount()
63+
tbl.insertRow(slider_row)
64+
tbl.setCellWidget(slider_row, 0, slider)
65+
66+
sb.setValue(sb.maximum())
67+
# Synchronous event emission and allows us to pass through the event filter
68+
QCoreApplication.sendEvent(slider, WHEEL_UP)
69+
# Assert the table widget scrolled but the slider didn't change
70+
assert sb.value() < sb.maximum()
71+
assert slider.value() == 0
72+
73+
# I can't know how to hear any more about tables
74+
tbl.close()

0 commit comments

Comments
 (0)