Skip to content

Commit faf0ba9

Browse files
committed
Replace QSpinBox with ScrubSpinBox
Introduce ScrubSpinBox (click-drag scrub behavior) in dlclivegui/gui/misc/drag_spinbox.py and replace several QSpinBox usages with it. ScrubSpinBox adds horizontal drag-to-adjust, Shift for fine control, Ctrl for coarse control, keyboard tracking disabled, a hint cursor and tooltip instructions. Updated imports in camera_config_dialog.py and main_window.py and swapped crop/bbox spin boxes to use the new widget to improve UX when adjusting numeric camera and bounding-box fields.
1 parent 1f5707e commit faf0ba9

3 files changed

Lines changed: 186 additions & 13 deletions

File tree

dlclivegui/gui/camera_config_dialog.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@
3232
QWidget,
3333
)
3434

35-
from dlclivegui.cameras import CameraFactory
36-
from dlclivegui.cameras.base import CameraBackend
37-
from dlclivegui.cameras.factory import DetectedCamera
38-
from dlclivegui.config import CameraSettings, MultiCameraSettings
39-
35+
from ..cameras import CameraFactory
36+
from ..cameras.base import CameraBackend
37+
from ..cameras.factory import DetectedCamera
38+
from ..config import CameraSettings, MultiCameraSettings
39+
from .misc.drag_spinbox import ScrubSpinBox
4040
from .misc.eliding_label import ElidingPathLabel
4141
from .misc.layouts import _make_two_field_row
4242

@@ -538,25 +538,25 @@ def _setup_ui(self) -> None:
538538
crop_layout = QHBoxLayout(crop_widget)
539539
crop_layout.setContentsMargins(0, 0, 0, 0)
540540

541-
self.cam_crop_x0 = QSpinBox()
541+
self.cam_crop_x0 = ScrubSpinBox()
542542
self.cam_crop_x0.setRange(0, 7680)
543543
self.cam_crop_x0.setPrefix("x0:")
544544
self.cam_crop_x0.setSpecialValueText("x0:None")
545545
crop_layout.addWidget(self.cam_crop_x0)
546546

547-
self.cam_crop_y0 = QSpinBox()
547+
self.cam_crop_y0 = ScrubSpinBox()
548548
self.cam_crop_y0.setRange(0, 4320)
549549
self.cam_crop_y0.setPrefix("y0:")
550550
self.cam_crop_y0.setSpecialValueText("y0:None")
551551
crop_layout.addWidget(self.cam_crop_y0)
552552

553-
self.cam_crop_x1 = QSpinBox()
553+
self.cam_crop_x1 = ScrubSpinBox()
554554
self.cam_crop_x1.setRange(0, 7680)
555555
self.cam_crop_x1.setPrefix("x1:")
556556
self.cam_crop_x1.setSpecialValueText("x1:None")
557557
crop_layout.addWidget(self.cam_crop_x1)
558558

559-
self.cam_crop_y1 = QSpinBox()
559+
self.cam_crop_y1 = ScrubSpinBox()
560560
self.cam_crop_y1.setRange(0, 4320)
561561
self.cam_crop_y1.setPrefix("y1:")
562562
self.cam_crop_y1.setSpecialValueText("y1:None")
@@ -1123,6 +1123,7 @@ def _clear_settings_form(self) -> None:
11231123
self.cam_device_name_label.setText("")
11241124
self.cam_index_label.setText("")
11251125
self.cam_backend_label.setText("")
1126+
self.detected_resolution_label.setText("—")
11261127
self.cam_width.setValue(0)
11271128
self.cam_height.setValue(0)
11281129
self.cam_fps.setValue(0.0)

dlclivegui/gui/main_window.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from ..utils.stats import format_dlc_stats
7373
from ..utils.utils import FPSTracker
7474
from .camera_config_dialog import CameraConfigDialog
75+
from .misc.drag_spinbox import ScrubSpinBox
7576
from .misc.eliding_label import ElidingPathLabel
7677
from .recording_manager import RecordingManager
7778
from .theme import LOGO, LOGO_ALPHA, AppStyle, apply_theme
@@ -636,25 +637,25 @@ def _build_bbox_group(self) -> QGroupBox:
636637
form.addRow(row_widget)
637638

638639
bbox_layout = QHBoxLayout()
639-
self.bbox_x0_spin = QSpinBox()
640+
self.bbox_x0_spin = ScrubSpinBox()
640641
self.bbox_x0_spin.setRange(0, 7680)
641642
self.bbox_x0_spin.setPrefix("x0:")
642643
self.bbox_x0_spin.setValue(0)
643644
bbox_layout.addWidget(self.bbox_x0_spin)
644645

645-
self.bbox_y0_spin = QSpinBox()
646+
self.bbox_y0_spin = ScrubSpinBox()
646647
self.bbox_y0_spin.setRange(0, 4320)
647648
self.bbox_y0_spin.setPrefix("y0:")
648649
self.bbox_y0_spin.setValue(0)
649650
bbox_layout.addWidget(self.bbox_y0_spin)
650651

651-
self.bbox_x1_spin = QSpinBox()
652+
self.bbox_x1_spin = ScrubSpinBox()
652653
self.bbox_x1_spin.setRange(0, 7680)
653654
self.bbox_x1_spin.setPrefix("x1:")
654655
self.bbox_x1_spin.setValue(100)
655656
bbox_layout.addWidget(self.bbox_x1_spin)
656657

657-
self.bbox_y1_spin = QSpinBox()
658+
self.bbox_y1_spin = ScrubSpinBox()
658659
self.bbox_y1_spin.setRange(0, 4320)
659660
self.bbox_y1_spin.setPrefix("y1:")
660661
self.bbox_y1_spin.setValue(100)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
from __future__ import annotations
2+
3+
from PySide6.QtCore import QPoint, Qt
4+
from PySide6.QtWidgets import QDoubleSpinBox, QSpinBox
5+
6+
7+
class _ScrubMixin:
8+
"""
9+
Shared scrubbing behavior for spinboxes.
10+
11+
Requires the subclass to implement:
12+
- _scrub_get_value() -> float
13+
- _scrub_set_value(v: float) -> None
14+
- _scrub_get_step() -> float
15+
- _scrub_coerce_step(step: float) -> float
16+
"""
17+
18+
def _scrub_init(self, *, scrub_button=Qt.LeftButton, pixels_per_step: int = 6) -> None:
19+
self._scrub_button = scrub_button
20+
self._pixels_per_step = max(1, int(pixels_per_step))
21+
self._dragging = False
22+
self._press_pos: QPoint | None = None
23+
self._press_value: float = 0.0
24+
self._accum_dx: int = 0
25+
26+
# Nice UX: don’t immediately rewrite value while typing
27+
self.setKeyboardTracking(False)
28+
29+
# Optional: give a hint cursor
30+
self.setCursor(Qt.SizeHorCursor)
31+
32+
# Initialize tooltip (keeps your pattern)
33+
self.setToolTip("")
34+
35+
def setToolTip(self, text: str, disable_instructions: bool = False) -> None:
36+
"""Override to optionally include scrubbing instructions in the tooltip.
37+
38+
Args:
39+
text: The main tooltip text to show (can be empty).
40+
disable_instructions: If True, do not include scrubbing instructions in the tooltip.
41+
"""
42+
if disable_instructions:
43+
super().setToolTip(text)
44+
return
45+
46+
# Add usage instructions to the tooltip (real HTML, not escaped entities)
47+
scrub_hint = "<i>Drag to adjust &nbsp; Shift=slow &nbsp; Ctrl=fast</i>"
48+
49+
if not text:
50+
super().setToolTip(f"<qt>{scrub_hint}</qt>")
51+
else:
52+
super().setToolTip(f"<qt>{text}<br>{scrub_hint}</qt>")
53+
54+
def mousePressEvent(self, event):
55+
if event.button() == self._scrub_button:
56+
self._press_pos = event.pos()
57+
self._press_value = float(self._scrub_get_value())
58+
self._accum_dx = 0
59+
self._dragging = False
60+
event.accept()
61+
return
62+
super().mousePressEvent(event)
63+
64+
def mouseMoveEvent(self, event):
65+
if self._press_pos is None:
66+
super().mouseMoveEvent(event)
67+
return
68+
69+
dx = event.pos().x() - self._press_pos.x()
70+
71+
# Only start scrubbing after a small threshold to preserve normal click-to-edit behavior
72+
if not self._dragging and abs(dx) < 3:
73+
super().mouseMoveEvent(event)
74+
return
75+
76+
self._dragging = True
77+
78+
# Convert pixel movement into steps (with accumulation so it feels smooth)
79+
self._accum_dx += dx
80+
self._press_pos = event.pos()
81+
82+
steps = int(self._accum_dx / self._pixels_per_step)
83+
if steps == 0:
84+
event.accept()
85+
return
86+
87+
# Consume used delta
88+
self._accum_dx -= steps * self._pixels_per_step
89+
90+
# Base step size comes from singleStep()
91+
step = float(self._scrub_get_step())
92+
93+
# Modifiers for fine/coarse control
94+
mods = event.modifiers()
95+
if mods & Qt.ShiftModifier:
96+
step *= 0.1
97+
if mods & Qt.ControlModifier:
98+
step *= 10.0
99+
100+
# Type-specific step constraints (e.g., int step must stay >= 1)
101+
step = float(self._scrub_coerce_step(step))
102+
103+
new_value = float(self._scrub_get_value()) + steps * step
104+
self._scrub_set_value(new_value)
105+
event.accept()
106+
107+
def mouseReleaseEvent(self, event):
108+
if self._press_pos is not None and event.button() == self._scrub_button:
109+
# If we were dragging, prevent the release from selecting text/clicking arrows etc.
110+
if self._dragging:
111+
event.accept()
112+
self._press_pos = None
113+
self._dragging = False
114+
return
115+
super().mouseReleaseEvent(event)
116+
117+
118+
class ScrubSpinBox(_ScrubMixin, QSpinBox):
119+
"""
120+
QSpinBox with click-drag scrubbing:
121+
- Drag horizontally to adjust the value
122+
- Shift: fine control
123+
- Ctrl: coarse control
124+
"""
125+
126+
def __init__(self, *args, scrub_button=Qt.LeftButton, pixels_per_step=6, **kwargs):
127+
super().__init__(*args, **kwargs)
128+
self._scrub_init(scrub_button=scrub_button, pixels_per_step=pixels_per_step)
129+
130+
# ---- type-specific hooks ----
131+
def _scrub_get_value(self) -> float:
132+
return float(int(self.value()))
133+
134+
def _scrub_set_value(self, v: float) -> None:
135+
self.setValue(int(round(v)))
136+
137+
def _scrub_get_step(self) -> float:
138+
# QSpinBox.singleStep() is int
139+
return float(int(self.singleStep()))
140+
141+
def _scrub_coerce_step(self, step: float) -> float:
142+
# For integers, ensure at least 1 step
143+
s = int(round(step))
144+
return float(max(1, s))
145+
146+
147+
class ScrubDoubleSpinBox(_ScrubMixin, QDoubleSpinBox):
148+
"""
149+
QDoubleSpinBox with click-drag scrubbing:
150+
- Drag horizontally to adjust the value
151+
- Shift: fine control
152+
- Ctrl: coarse control
153+
"""
154+
155+
def __init__(self, *args, scrub_button=Qt.LeftButton, pixels_per_step=6, **kwargs):
156+
super().__init__(*args, **kwargs)
157+
self._scrub_init(scrub_button=scrub_button, pixels_per_step=pixels_per_step)
158+
159+
# ---- type-specific hooks ----
160+
def _scrub_get_value(self) -> float:
161+
return float(self.value())
162+
163+
def _scrub_set_value(self, v: float) -> None:
164+
self.setValue(float(v))
165+
166+
def _scrub_get_step(self) -> float:
167+
return float(self.singleStep())
168+
169+
def _scrub_coerce_step(self, step: float) -> float:
170+
# For doubles, allow fractional steps (but avoid zero)
171+
return step if abs(step) > 1e-12 else float(self.singleStep())

0 commit comments

Comments
 (0)