Repository: napari/napari

Issue:
Change `QtLargeIntSpinBox` for Labels to inherit directly from `QAbstractSpinBox`
## 🚀 Feature
Following on from #2608 and #2640 , an ideal implementation would inherit from `QAbstractSpinBox` and therefore support any integer types for Labels layers, as well as overriding the necessary private methods of the `QAbstractSpinBox`.

## Motivation
This would provide all the support required for unbound integers without resorting to some 'hacky' signal emissions as was required in #2640 to ensure `valueChanged` was correctly emitted.

## Pitch

@tlambert03 has written a basic implementation of this spin box already:

```python
from qtpy.QtCore import QSize, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox


class AnyIntValidator(QValidator):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

    def validate(self, input: str, pos: int):
        if input.isnumeric():
            return 2, input, len(input)
        else:
            return 0, input, len(input)


class QtLargeIntSpinBox(QAbstractSpinBox):
    """An integer spinboxes backed by unbound python integer

    Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
    This could become limiting, particularly in large dense segmentations.
    This class behaves like a ``QSpinBox`` backed by an unbound python int.

    Does not yet support "prefix", "suffix" or "specialValue" like QSpinBox.
    """

    textChanged = Signal(str)
    valueChanged = Signal(int)

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self._value: int = 0
        self._minimum: int = 0
        self._maximum: int = 2 ** 64 - 1
        self._single_step: int = 1
        validator = AnyIntValidator(self)
        self.lineEdit().setValidator(validator)
        self.lineEdit().textChanged.connect(self._editor_text_changed)
        self.setValue(0)

    def value(self):
        return self._value

    def setValue(self, value):
        self._value, old = self._bound(int(value)), self._value
        self._updateEdit()
        self.update()
        if self._value != old:
            self.textChanged.emit(self.lineEdit().displayText())
            self.valueChanged.emit(self._value)

    def _updateEdit(self):
        new_text = str(self._value)
        if self.lineEdit().text() == new_text:
            return
        self.lineEdit().setText(new_text)

    def singleStep(self):
        return self._single_step

    def setSingleStep(self, step):
        self._single_step = int(step)

    def minimum(self):
        return self._minimum

    def setMinimum(self, min):
        self._minimum = int(min)

    def maximum(self):
        return self._maximum

    def setMaximum(self, max):
        self._maximum = int(max)

    def setRange(self, minimum, maximum):
        self.setMinimum(minimum)
        self.setMaximum(maximum)

    def stepBy(self, steps: int) -> None:
        step = self._single_step
        self.setValue(self._bound(self._value + (step * steps)))

    def _editor_text_changed(self, t):
        state, *_ = self.validate(t, self.lineEdit().cursorPosition())
        if state == QValidator.Acceptable:
            self.setValue(int(t))

    def _bound(self, value):
        return max(self._minimum, min(self._maximum, value))

    def stepEnabled(self):
        flags = QAbstractSpinBox.StepNone
        if self.isReadOnly():
            return flags
        if self._value < self._maximum:
            flags |= QAbstractSpinBox.StepUpEnabled
        if self._value > self._minimum:
            flags |= QAbstractSpinBox.StepDownEnabled
        return flags

    def sizeHint(self):
        self.ensurePolished()
        fm = QFontMetrics(self.font())
        h = self.lineEdit().sizeHint().height()
        w = fm.horizontalAdvance(str(self._value)) + 3
        w = max(36, w)
        opt = QStyleOptionSpinBox()
        self.initStyleOption(opt)
        hint = QSize(w, h)
        return self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self)
```



Discussion:

Resolving patch:
diff --git a/napari/_qt/layer_controls/qt_labels_controls.py b/napari/_qt/layer_controls/qt_labels_controls.py
--- a/napari/_qt/layer_controls/qt_labels_controls.py
+++ b/napari/_qt/layer_controls/qt_labels_controls.py
@@ -91,9 +91,9 @@ def __init__(self, layer):
         self.layer.events.color_mode.connect(self._on_color_mode_change)
 
         # selection spinbox
-        self.selectionSpinBox = QtLargeIntSpinBox(self.layer.data.dtype)
+        self.selectionSpinBox = QtLargeIntSpinBox()
+        self.selectionSpinBox.set_dtype(self.layer.data.dtype)
         self.selectionSpinBox.setKeyboardTracking(False)
-        self.selectionSpinBox.setMinimum(0)
         self.selectionSpinBox.valueChanged.connect(self.changeSelection)
         self.selectionSpinBox.setAlignment(Qt.AlignCenter)
         self._on_selected_label_change()
@@ -123,13 +123,11 @@ def __init__(self, layer):
         ndim_sb.setAlignment(Qt.AlignCenter)
         self._on_n_edit_dimensions_change()
 
-        contour_sb = QtLargeIntSpinBox(self.layer.data.dtype)
-        contour_sb.setToolTip(trans._('display contours of labels'))
-        contour_sb.valueChanged.connect(self.change_contour)
-        self.contourSpinBox = contour_sb
+        self.contourSpinBox = QtLargeIntSpinBox()
+        self.contourSpinBox.set_dtype(self.layer.data.dtype)
+        self.contourSpinBox.setToolTip(trans._('display contours of labels'))
+        self.contourSpinBox.valueChanged.connect(self.change_contour)
         self.contourSpinBox.setKeyboardTracking(False)
-        self.contourSpinBox.setSingleStep(1)
-        self.contourSpinBox.setMinimum(0)
         self.contourSpinBox.setAlignment(Qt.AlignCenter)
         self._on_contour_change()
 
diff --git a/napari/_qt/widgets/qt_large_int_spinbox.py b/napari/_qt/widgets/qt_large_int_spinbox.py
--- a/napari/_qt/widgets/qt_large_int_spinbox.py
+++ b/napari/_qt/widgets/qt_large_int_spinbox.py
@@ -1,84 +1,170 @@
-import logging
+from enum import Enum
 
-import numpy as np
-from qtpy.QtCore import Signal
-from qtpy.QtWidgets import QDoubleSpinBox
+from qtpy.QtCore import QSize, Qt, Signal
+from qtpy.QtGui import QFontMetrics, QValidator
+from qtpy.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
 
-logger = logging.getLogger(__name__)
 
+class EmitPolicy(Enum):
+    EmitIfChanged = 0
+    AlwaysEmit = 1
+    NeverEmit = 2
 
-class QtLargeIntSpinBox(QDoubleSpinBox):
-    """
-    A class for integer spinboxes backed by a double (i.e. float64).
+
+class AnyIntValidator(QValidator):
+    def __init__(self, parent=None) -> None:
+        super().__init__(parent)
+
+    def validate(self, input: str, pos: int):
+        if not input.lstrip("-"):
+            return QValidator.Intermediate, input, len(input)
+        if input.lstrip("-").isnumeric():
+            return QValidator.Acceptable, input, len(input)
+        return QValidator.Invalid, input, len(input)
+
+
+class QtLargeIntSpinBox(QAbstractSpinBox):
+    """An integer spinboxes backed by unbound python integer
 
     Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
     This could become limiting, particularly in large dense segmentations.
-    This class behaves like a ``QSpinBox`` backed by a signed 54-bit integer.
+    This class behaves like a ``QSpinBox`` backed by an unbound python int.
 
-    Instances are associated with a ``numpy.dtype``
-    which can further constrain their maximum range,
-    and ensures that values are clamped and cast correctly
-    when using setter and getter methods.
+    Does not yet support "prefix", "suffix" or "specialValue" like QSpinBox.
     """
 
-    MAX_FLOAT64_INT = 2 ** 53
-    MIN_FLOAT64_INT = -MAX_FLOAT64_INT
+    textChanged = Signal(str)
+    valueChanged = Signal(object)
 
-    valueChanged = Signal(int)
-
-    def __init__(self, dtype=int, parent=None):
+    def __init__(self, parent=None) -> None:
         super().__init__(parent)
-        self._dtype = np.dtype(dtype)
-        if not np.issubdtype(self._dtype, np.integer):
-            raise ValueError(f"Spinbox dtype must be integral, got {dtype}")
-        iinfo = np.iinfo(self._dtype)
-
-        self.setSingleStep(1)
-        super().setDecimals(0)
-
-        self._min = self._dtype.type(max(iinfo.min, self.MIN_FLOAT64_INT))
-        self._max = self._dtype.type(min(iinfo.max, self.MAX_FLOAT64_INT))
-        self.setRange(self._min, self._max)
-
-    def _cast(self, value):
-        actual_val = max(self._min, min(self._max, self._dtype.type(value)))
-        if actual_val != value:
-            logger.warning(
-                "Value %s is not representable by %s(%s); using %s",
-                value,
-                type(self).__qualname__,
-                self._dtype,
-                actual_val,
-            )
-        return actual_val
+        self._value: int = 0
+        self._minimum: int = 0
+        self._maximum: int = 2 ** 64 - 1
+        self._single_step: int = 1
+        self._pending_emit = False
+        validator = AnyIntValidator(self)
+        self.lineEdit().setValidator(validator)
+        self.lineEdit().textChanged.connect(self._editor_text_changed)
+        self.setValue(0)
 
-    def setSingleStep(self, val):
-        return super().setSingleStep(int(val))
-
-    def singleStep(self):
-        return int(super().singleStep())
-
-    def setDecimals(self):
-        raise NotImplementedError
+    def value(self):
+        return self._value
 
     def setValue(self, value):
-        super().setValue(self._cast(value))
+        self._setValue(value, EmitPolicy.EmitIfChanged)
+
+    def _setValue(self, value, policy):
+        self._value, old = self._bound(int(value)), self._value
+        self._pending_emit = False
+        self._updateEdit()
+        self.update()
+
+        if policy is EmitPolicy.AlwaysEmit or (
+            policy is EmitPolicy.EmitIfChanged and self._value != old
+        ):
+            self._pending_emit = False
+            self.textChanged.emit(self.lineEdit().displayText())
+            self.valueChanged.emit(self._value)
+
+    def _updateEdit(self):
+        new_text = str(self._value)
+        if self.lineEdit().text() == new_text:
+            return
+        self.lineEdit().setText(new_text)
 
-    def value(self):
-        return self._cast(super().value())
+    def singleStep(self):
+        return self._single_step
 
-    def setMinimum(self, value):
-        super().setMinimum(self._cast(value))
+    def setSingleStep(self, step):
+        self._single_step = int(step)
 
     def minimum(self):
-        return self._cast(super().minimum())
+        return self._minimum
 
-    def setMaximum(self, value):
-        super().setMaximum(self._cast(value))
+    def setMinimum(self, min):
+        self._minimum = int(min)
 
     def maximum(self):
-        return self._cast(super().maximum())
-
-    def setRange(self, min_, max_):
-        self.setMinimum(min_)
-        self.setMaximum(max_)
+        return self._maximum
+
+    def setMaximum(self, max):
+        self._maximum = int(max)
+
+    def setRange(self, minimum, maximum):
+        self.setMinimum(minimum)
+        self.setMaximum(maximum)
+
+    def _interpret(self, policy):
+        text = self.lineEdit().displayText() or str(self._value)
+        v = int(text)
+        self._setValue(v, policy)
+
+    def focusOutEvent(self, e) -> None:
+        if self._pending_emit:
+            self._interpret(EmitPolicy.EmitIfChanged)
+        return super().focusOutEvent(e)
+
+    def closeEvent(self, e) -> None:
+        if self._pending_emit:
+            self._interpret(EmitPolicy.EmitIfChanged)
+        return super().closeEvent(e)
+
+    def keyPressEvent(self, e) -> None:
+        if e.key() in (Qt.Key_Enter, Qt.Key_Return):
+            self._interpret(
+                EmitPolicy.AlwaysEmit
+                if self.keyboardTracking()
+                else EmitPolicy.EmitIfChanged
+            )
+        return super().keyPressEvent(e)
+
+    def stepBy(self, steps: int) -> None:
+        step = self._single_step
+        old = self._value
+        e = EmitPolicy.EmitIfChanged
+        if self._pending_emit:
+            self._interpret(EmitPolicy.NeverEmit)
+            if self._value != old:
+                e = EmitPolicy.AlwaysEmit
+        self._setValue(self._bound(self._value + (step * steps)), e)
+
+    def _editor_text_changed(self, t):
+        if self.keyboardTracking():
+            self._setValue(int(t), EmitPolicy.EmitIfChanged)
+            self.lineEdit().setFocus()
+            self._pending_emit = False
+        else:
+            self._pending_emit = True
+
+    def _bound(self, value):
+        return max(self._minimum, min(self._maximum, value))
+
+    def stepEnabled(self):
+        flags = QAbstractSpinBox.StepNone
+        if self.isReadOnly():
+            return flags
+        if self._value < self._maximum:
+            flags |= QAbstractSpinBox.StepUpEnabled
+        if self._value > self._minimum:
+            flags |= QAbstractSpinBox.StepDownEnabled
+        return flags
+
+    def sizeHint(self):
+        self.ensurePolished()
+        fm = QFontMetrics(self.font())
+        h = self.lineEdit().sizeHint().height()
+        w = fm.horizontalAdvance(str(self._value)) + 3
+        w = max(36, w)
+        opt = QStyleOptionSpinBox()
+        self.initStyleOption(opt)
+        hint = QSize(w, h)
+        return self.style().sizeFromContents(
+            QStyle.CT_SpinBox, opt, hint, self
+        )
+
+    def set_dtype(self, dtype):
+        import numpy as np
+
+        iinfo = np.iinfo(dtype)
+        self.setRange(iinfo.min, iinfo.max)


Test patch:
diff --git a/napari/_qt/widgets/_tests/test_qt_large_int_spinbox.py b/napari/_qt/widgets/_tests/test_qt_large_int_spinbox.py
--- a/napari/_qt/widgets/_tests/test_qt_large_int_spinbox.py
+++ b/napari/_qt/widgets/_tests/test_qt_large_int_spinbox.py
@@ -1,58 +1,101 @@
 import numpy as np
 import pytest
+from qtpy.QtCore import Qt
 
-from ..qt_large_int_spinbox import QtLargeIntSpinBox
+from napari._qt.widgets.qt_large_int_spinbox import QtLargeIntSpinBox
+
+
+def test_large_spinbox(qtbot):
+    sb = QtLargeIntSpinBox()
+    qtbot.addWidget(sb)
+
+    for e in range(2, 100, 2):
+        sb.setMaximum(10 ** e + 2)
+        with qtbot.waitSignal(sb.valueChanged) as sgnl:
+            sb.setValue(10 ** e)
+        assert sgnl.args == [10 ** e]
+        assert sb.value() == 10 ** e
+
+        sb.setMinimum(-(10 ** e) - 2)
+
+        with qtbot.waitSignal(sb.valueChanged) as sgnl:
+            sb.setValue(-(10 ** e))
+        assert sgnl.args == [-(10 ** e)]
+        assert sb.value() == -(10 ** e)
 
 
 @pytest.mark.parametrize(
-    ["dtype"],
-    [
-        (np.uint8,),
-        (np.int8,),
-        (np.uint16,),
-        (np.int16,),
-        (np.uint32,),
-        (np.int32,),
-        ("uint8",),
-        (np.dtype("uint8"),),
-    ],
+    "dtype",
+    [int, 'uint8', np.uint8, 'int8', 'uint16', 'int16', 'uint32', 'int32'],
 )
 def test_clamp_dtype(qtbot, dtype):
-    spinbox = QtLargeIntSpinBox(dtype)
+    sb = QtLargeIntSpinBox()
+    qtbot.addWidget(sb)
+    sb.set_dtype(dtype)
     iinfo = np.iinfo(dtype)
-    assert spinbox.minimum() == iinfo.min
-    assert spinbox.maximum() == iinfo.max
+    assert sb.minimum() == iinfo.min
+    assert sb.maximum() == iinfo.max
 
 
-def test_clamp_float(qtbot):
-    dtype = np.uint64
-    spinbox = QtLargeIntSpinBox(dtype)
-    iinfo = np.iinfo(dtype)
-    assert spinbox.minimum() == iinfo.min
-    assert spinbox.maximum() < iinfo.max
+def test_large_spinbox_type(qtbot):
+    sb = QtLargeIntSpinBox()
+    qtbot.addWidget(sb)
+
+    assert isinstance(sb.value(), int)
+
+    sb.setValue(1.1)
+    assert isinstance(sb.value(), int)
+    assert sb.value() == 1
+
+    sb.setValue(1.9)
+    assert isinstance(sb.value(), int)
+    assert sb.value() == 1
+
+
+def test_large_spinbox_signals(qtbot):
+    sb = QtLargeIntSpinBox()
+    qtbot.addWidget(sb)
+
+    with qtbot.waitSignal(sb.valueChanged) as sgnl:
+        sb.setValue(200)
+    assert sgnl.args == [200]
 
+    with qtbot.waitSignal(sb.textChanged) as sgnl:
+        sb.setValue(240)
+    assert sgnl.args == ['240']
 
-def test_digitize_value(qtbot):
-    dtype = np.dtype(np.uint32)
-    spinbox = QtLargeIntSpinBox(dtype)
 
-    assert isinstance(spinbox.value(), dtype.type)
+def test_keyboard_tracking(qtbot):
+    sb = QtLargeIntSpinBox()
+    qtbot.addWidget(sb)
+    assert sb.value() == 0
+    sb.setKeyboardTracking(False)
+    with qtbot.assertNotEmitted(sb.valueChanged):
+        sb.lineEdit().setText('20')
+    assert sb.lineEdit().text() == '20'
+    assert sb.value() == 0
+    assert sb._pending_emit is True
 
-    spinbox.setValue(1.1)
-    assert isinstance(spinbox.value(), dtype.type)
-    assert spinbox.value() == 1
+    with qtbot.waitSignal(sb.valueChanged) as sgnl:
+        qtbot.keyPress(sb, Qt.Key_Enter)
+    assert sgnl.args == [20]
+    assert sb._pending_emit is False
 
-    spinbox.setValue(1.9)
-    assert isinstance(spinbox.value(), dtype.type)
-    assert spinbox.value() == 1
+    sb.setKeyboardTracking(True)
+    with qtbot.waitSignal(sb.valueChanged) as sgnl:
+        sb.lineEdit().setText('25')
+    assert sb._pending_emit is False
+    assert sgnl.args == [25]
 
 
-def test_event_emits_int(qtbot):
-    dtype = np.dtype(np.int32)
+def test_in_viewer(make_napari_viewer):
+    viewer = make_napari_viewer()
+    viewer._new_labels()
 
-    def fn(value):
-        assert isinstance(value, dtype.type)
+    layer_controls = viewer.window.qt_viewer.dockLayerControls.widget()
+    label_spinbox = layer_controls.widgets[viewer.layers[0]].selectionSpinBox
+    label_spinbox.setValue(5)
 
-    spinbox = QtLargeIntSpinBox(dtype)
-    spinbox.valueChanged.connect(fn)
-    spinbox.setValue(1)
+    label_spinbox.setMaximum(2 ** 64)
+    label_spinbox.setValue(2 ** 64 - 1)
+    assert label_spinbox.value() == 2 ** 64 - 1