Timeline slider¶
The timeline slider is the horizontal scrubber strip below the canvas. It is implemented as TimelineSliderWidget in disklavier_visualizer/ui/timeline_slider.py:57, with a custom _ClickJumpSlider subclass (timeline_slider.py:30) for click-to-jump behaviour.
Layout¶
┌────────────────────────────────────────────────────────────────────────┐
│ ━━━━━━━━━━━━━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 03:12.847 / 04:21.500 │
└────────────────────────────────────────────────────────────────────────┘
↑ slider (stretch=1) ↑ MM:SS.mmm label
A QHBoxLayout with 8 px horizontal margins and 4 px vertical margins. The slider takes all the horizontal space; the label is fixed at a minimum width of 160 px so its monospace text doesn't shift the slider as digits change.
Internal units¶
The slider uses integer milliseconds internally:
| Value | Meaning |
|---|---|
setRange(0, 0) |
Initial state — disabled, no MIDI |
setRange(0, int(round(duration * 1000))) |
After set_duration |
setSingleStep(10) |
One arrow-key press = 10 ms |
setPageStep(1000) |
Page-up / page-down = 1000 ms |
setTracking(True) |
valueChanged fires continuously while dragging |
The position_changed(float) signal exposed externally is in seconds (ms / 1000.0), so external code never has to know about the millisecond granularity.
Time format¶
The label uses _format_mmss:
def _format_mmss(seconds: float) -> str:
if seconds < 0:
seconds = 0.0
m = int(seconds // 60)
s = seconds - m * 60
return f"{m:02d}:{s:06.3f}"
Examples:
| Seconds | Formatted |
|---|---|
0.0 |
00:00.000 |
3.5 |
00:03.500 |
192.847 |
03:12.847 |
261.5 |
04:21.500 |
The label always shows current / total. Both halves use the same format.
The font family is set to monospace after construction so the digits stay aligned as they update; otherwise on most systems Qt's default proportional font would jitter the layout horizontally as digits widen and narrow.
Interactions¶
Drag the thumb¶
Standard QSlider drag — press and hold the thumb, drag horizontally. With setTracking(True), valueChanged(int ms) fires on every mouse-move tick, so the canvas updates in real time, not just on release.
Click the trough¶
Vanilla QSlider only page-steps when you click the trough (a click jumps the thumb by one page step toward the click, not directly to the click). The custom _ClickJumpSlider overrides mousePressEvent to convert a trough click directly into a thumb position:
if event.button() == Qt.LeftButton:
handle = subControlRect(SC_SliderHandle)
if not handle.contains(event.pos()):
groove = subControlRect(SC_SliderGroove)
pos = event.pos().x() - groove.x()
span = groove.width()
value = QStyle.sliderValueFromPosition(min, max, pos, span, opt.upsideDown)
self.setValue(value)
super().mousePressEvent(event)
After the override sets the value, the parent mousePressEvent is still called, so the user can immediately drag from the new position without releasing the button.
Disabled state¶
The slider is disabled (setEnabled(False)) when duration == 0 (no MIDI loaded). Clicking or dragging in that state has no effect; the trough renders greyed-out.
set_duration is called by the main window from _open_midi after a successful parse:
self._slider.set_duration(adapter.duration)
This expands the range to [0, duration_ms] and enables the slider.
Two-way bind without echo¶
The slider is wired bidirectionally with the canvas:
canvas.position_changed → slider.set_position
slider.position_changed → canvas.set_position
If both set_positions emitted position_changed, a single user action would bounce forever. The slider breaks the loop with blockSignals around the internal setValue:
def set_position(self, seconds: float):
self._position = max(0.0, min(float(seconds), self._duration))
ms = int(round(self._position * 1000))
self._slider.blockSignals(True)
self._slider.setValue(ms)
self._slider.blockSignals(False)
self._update_label()
set_position is the programmatic path — it never emits position_changed.
_on_slider_value_changed, on the other hand, does emit:
def _on_slider_value_changed(self, ms: int):
self._position = ms / 1000.0
self._update_label()
self.position_changed.emit(self._position)
This is wired to the slider's valueChanged signal, which only fires for genuine user input (because programmatic setValue calls happen with signals blocked).
The canvas uses a different but equivalent pattern: an early-return when the new position equals the current. Either way, a single user action settles in one event-loop tick.
set_duration: rounding and clamping¶
def set_duration(self, seconds: float):
self._duration = max(0.0, float(seconds))
max_ms = int(round(self._duration * 1000))
self._slider.blockSignals(True)
self._slider.setRange(0, max_ms)
self._slider.setEnabled(self._duration > 0)
self._position = min(self._position, self._duration)
self._slider.setValue(int(round(self._position * 1000)))
self._slider.blockSignals(False)
self._update_label()
Note the explicit:
max(0.0, ...)— negative durations are coerced to 0.min(self._position, self._duration)— if a previous file had a longer duration, the position is clamped into the new range so the slider thumb doesn't disappear off the right edge.blockSignals(True/False)around bothsetRangeandsetValueto avoid an echo throughvalueChanged.
Public API¶
Signals¶
| Signal | When |
|---|---|
position_changed(float) |
User-driven slider drag or trough click. Not emitted on programmatic set_position or set_duration. Emits seconds, not milliseconds. |
Methods¶
| Method | Purpose |
|---|---|
set_duration(seconds: float) |
Set the maximum scrubbable time. Enables/disables the slider. Clamps current position. |
set_position(seconds: float) |
Set the thumb position without re-emitting. Used by the canvas → slider bind. |
There are no read-only properties for current position or duration — the main window doesn't need them (it tracks the playhead via the canvas, which is the source of truth).
Edge cases¶
- Sub-millisecond precision is lost. The slider rounds to the nearest millisecond, so a canvas tick step of 0.5 ms might quantize to a 1 ms or 0 ms slider movement. This is invisible at the slider's pixel resolution but is the reason the canvas keeps its own float
_current_timerather than reading back from the slider. - Files longer than ~24 days overflow
int32.setRangeacceptsint, and Qt internally usesint32for slider values. A 24-day MIDI file (≈ 2.1 billion ms) is well past Qt's slider range. Disklavier recordings are minutes long, so this is purely theoretical, but worth noting if you ever try to visualize a multi-day stitched MIDI. - Duration changes while the user is dragging. Not possible in the current UI:
set_durationis only called on file load, and the user can't drag the slider while a file dialog is open.