class SpanSlider(widgets.Slider):
value_changed = core.Signal(object)
lower_pos_changed = core.Signal(float)
upper_pos_changed = core.Signal(float)
slider_pressed = core.Signal(object)
def __init__(self, *args, object_name: str = "span_slider", **kwargs):
super().__init__("horizontal", *args, object_name=object_name, **kwargs)
self.rangeChanged.connect(self.update_range)
self.sliderReleased.connect(self._move_pressed_handle)
self.lower_val = 0.0
self.upper_val = 0.0
self.lower_pos = 0.0
self.upper_pos = 0.0
self.offset = 0
self.position = 0.0
self.last_pressed: str | None = None
self.upper_pressed = widgets.Style.SubControl.SC_None
self.lower_pressed = widgets.Style.SubControl.SC_None
self.movement: MovementModeStr = "no_crossing"
self._main_control: Literal["lower", "upper"] = "lower"
self._first_movement = False
self._block_tracking = False
dark_color = self.palette().color(gui.Palette.ColorRole.Dark)
self.gradient_left = dark_color.lighter(110)
self.gradient_right = dark_color.lighter(110)
def mousePressEvent(self, event):
if self.minimum() == self.maximum() or event.buttons() ^ event.button():
event.ignore()
return
self.upper_pressed = self._handle_mouse_press(
event.position(), self.upper_pressed, self.upper_val, "upper"
)
if self.upper_pressed != HANDLE_STYLE:
self.lower_pressed = self._handle_mouse_press(
event.position(), self.lower_pressed, self.lower_val, "lower"
)
self._first_movement = True
event.accept()
def mouseMoveEvent(self, event):
if self.lower_pressed != HANDLE_STYLE and self.upper_pressed != HANDLE_STYLE:
event.ignore()
return
opt = widgets.StyleOptionSlider()
self.initStyleOption(opt)
m = self.style().pixelMetric(
widgets.Style.PixelMetric.PM_MaximumDragDistance, opt, self
)
pixel_pos = int(self.pick(event.position()) - self.offset)
new_pos = float(self._pixel_pos_to_value(pixel_pos))
if m >= 0:
r = self.rect().adjusted(-m, -m, m, m)
if not r.contains(event.position().toPoint()):
new_pos = self.position
# pick the preferred handle on the first movement
if self._first_movement:
if self.lower_val == self.upper_val:
if new_pos < self.get_lower_value():
self._swap_controls()
self._first_movement = False
else:
self._first_movement = False
match HANDLE_STYLE, self.movement:
case self.lower_pressed, "no_crossing":
new_pos = min(new_pos, self.upper_val)
self.set_lower_pos(new_pos)
case self.lower_pressed, "no_overlap":
new_pos = min(new_pos, self.upper_val - 1)
self.set_lower_pos(new_pos)
case self.lower_pressed, "free" if new_pos > self.upper_val:
self._swap_controls()
self.set_upper_pos(new_pos)
case self.upper_pressed, "no_crossing":
new_pos = max(new_pos, self.get_lower_value())
self.set_upper_pos(new_pos)
case self.upper_pressed, "no_overlap":
new_pos = max(new_pos, self.get_lower_value() + 1)
self.set_upper_pos(new_pos)
case self.upper_pressed, "free" if new_pos < self.lower_val:
self._swap_controls()
self.set_lower_pos(new_pos)
event.accept()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.setSliderDown(False)
self.lower_pressed = self.upper_pressed = widgets.Style.SubControl.SC_None
self.update()
def paintEvent(self, event):
painter = widgets.StylePainter(self)
# ticks
opt = widgets.StyleOptionSlider()
self.initStyleOption(opt)
opt.subControls = widgets.Style.SubControl.SC_SliderTickmarks
painter.draw_complex_control("slider", opt)
# groove
opt.sliderPosition = 20
opt.sliderValue = 0
opt.subControls = GROOVE_STYLE
painter.draw_complex_control("slider", opt)
# handle rects
opt.sliderPosition = int(self.lower_pos)
lr = self.style().subControlRect(SLIDER_STYLE, opt, HANDLE_STYLE, self)
lrv = self.pick(lr.center())
opt.sliderPosition = int(self.upper_pos)
ur = self.style().subControlRect(SLIDER_STYLE, opt, HANDLE_STYLE, self)
urv = self.pick(ur.center())
# span
minv = min(lrv, urv)
maxv = max(lrv, urv)
c = self.style().subControlRect(SLIDER_STYLE, opt, GROOVE_STYLE, self).center()
if self.is_horizontal():
rect = core.Rect(core.Point(minv, c.y() - 2), core.Point(maxv, c.y() + 1))
else:
rect = core.Rect(core.Point(c.x() - 2, minv), core.Point(c.x() + 1, maxv))
self._draw_span(painter, rect)
# handles
if self.last_pressed == "lower":
self.draw_handle(painter, "upper")
self.draw_handle(painter, "lower")
else:
self.draw_handle(painter, "lower")
self.draw_handle(painter, "upper")
def get_lower_value(self) -> float:
return min(self.lower_val, self.upper_val)
def set_lower_value(self, lower: float):
self.set_span(lower, self.upper_val)
def get_upper_value(self) -> float:
return max(self.lower_val, self.upper_val)
def set_upper_value(self, upper: float):
self.set_span(self.lower_val, upper)
def on_value_change(self):
self.value_changed.emit((self.lower_val, self.upper_val))
def get_value(self) -> tuple[float, float]:
return (self.lower_val, self.upper_val)
def set_value(self, value: tuple[float, float]):
self.set_lower_value(value[0])
self.set_upper_value(value[1])
def get_movement_mode(self) -> MovementModeStr:
return self.movement
def set_movement_mode(self, mode: MovementModeStr):
"""Set movement mode.
Args:
mode: movement mode for the main window
Raises:
ValueError: movement mode type does not exist
"""
if mode not in MOVEMENT_MODE:
raise ValueError("Invalid movement mode")
self.movement = mode
def set_span(self, lower: float, upper: float):
low = clamp(min(lower, upper), self.minimum(), self.maximum())
upp = clamp(max(lower, upper), self.minimum(), self.maximum())
changed = False
if low != self.lower_val:
self.lower_val = low
self.lower_pos = low
changed = True
if upp != self.upper_val:
self.upper_val = upp
self.upper_pos = upp
changed = True
if changed:
self.on_value_change()
self.update()
def set_lower_pos(self, lower: float):
if self.lower_pos == lower:
return
self.lower_pos = lower
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.lower_pos_changed.emit(lower)
if self.hasTracking() and not self._block_tracking:
main = self._main_control == "lower"
self.trigger_action("move", main)
def set_upper_pos(self, upper: float):
if self.upper_pos == upper:
return
self.upper_pos = upper
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.upper_pos_changed.emit(upper)
if self.hasTracking() and not self._block_tracking:
main = self._main_control == "upper"
self.trigger_action("move", main)
def set_left_color(self, color: datatypes.ColorType):
self.gradient_left = colors.get_color(color)
self.update()
def set_right_color(self, color: datatypes.ColorType):
self.gradient_right = colors.get_color(color)
self.update()
def _move_pressed_handle(self):
if self.last_pressed == "lower":
if self.lower_pos != self.lower_val:
main = self._main_control == "lower"
self.trigger_action("move", main)
elif self.last_pressed == "upper" and self.upper_pos != self.upper_val:
main = self._main_control == "upper"
self.trigger_action("move", main)
def pick(self, p: datatypes.PointType) -> int:
if isinstance(p, tuple):
return p[0] if self.is_horizontal() else p[1]
else:
return p.x() if self.is_horizontal() else p.y()
def trigger_action(self, action: ActionStr, main: bool):
value = 0.0
no = False
up = False
my_min = self.minimum()
my_max = self.maximum()
self._block_tracking = True
main_control = main and self._main_control == "upper"
alt_control = not main and self._main_control == "lower"
is_upper_handle = main_control or alt_control
val = self.upper_val if is_upper_handle else self.lower_val
match action:
case "single_step_add":
up = is_upper_handle
value = clamp(val + self.singleStep(), my_min, my_max)
case "single_step_sub":
up = is_upper_handle
value = clamp(val - self.singleStep(), my_min, my_max)
case "to_minimum":
up = is_upper_handle
value = my_min
case "to_maximum":
up = is_upper_handle
value = my_max
case "move":
up = is_upper_handle
no = True
case "none":
no = True
if not no and not up:
match self.movement:
case "no_crossing":
value = min(value, self.upper_val)
case "no_overlap":
value = min(value, self.upper_val - 1)
case "free" if value > self.upper_val:
self._swap_controls()
self.set_upper_pos(value)
case "free":
self.set_lower_pos(value)
elif not no:
match self.movement:
case "no_crossing":
value = max(value, self.lower_val)
case "no_overlap":
value = max(value, self.lower_val + 1)
case "free" if value < self.lower_val:
self._swap_controls()
self.set_lower_pos(value)
case "free":
self.set_upper_pos(value)
self._block_tracking = False
self.set_lower_value(self.lower_pos)
self.set_upper_value(self.upper_pos)
def _swap_controls(self):
self.lower_val, self.upper_val = self.upper_val, self.lower_val
self.lower_pressed, self.upper_pressed = self.upper_pressed, self.lower_pressed
self.last_pressed = "upper" if self.last_pressed == "lower" else "lower"
self._main_control = "upper" if self._main_control == "lower" else "lower"
def update_range(self, min_, max_):
# set_span() takes care of keeping span in range
self.set_span(self.lower_val, self.upper_val)
def _setup_painter(
self,
painter: widgets.StylePainter,
orientation: Literal["horizontal", "vertical"],
x1: int,
y1: int,
x2: int,
y2: int,
):
highlight = self.palette().color(gui.Palette.ColorRole.Highlight)
gradient = gui.LinearGradient(x1, y1, x2, y2)
gradient[0] = highlight.darker(120)
gradient[1] = highlight.lighter(108)
painter.setBrush(gradient)
val = 130 if orientation == "horizontal" else 150
painter.set_pen(color=highlight.darker(val), width=0)
def _draw_span(self, painter: widgets.StylePainter, rect: core.Rect):
opt = widgets.StyleOptionSlider()
self.initStyleOption(opt)
painter.set_pen(color=self.gradient_left, width=0)
groove = self.style().subControlRect(SLIDER_STYLE, opt, GROOVE_STYLE, self)
if opt.is_horizontal():
groove.adjust(0, 0, -1, 0)
self._setup_painter(
painter,
opt.get_orientation(),
groove.center().x(),
groove.top(),
groove.center().x(),
groove.bottom(),
)
else:
groove.adjust(0, 0, 0, -1)
self._setup_painter(
painter,
opt.get_orientation(),
groove.left(),
groove.center().y(),
groove.right(),
groove.center().y(),
)
# draw groove
intersected = core.RectF(rect.intersected(groove))
gradient = gui.LinearGradient(intersected.topLeft(), intersected.topRight())
gradient[0] = self.gradient_left
gradient[1] = self.gradient_right
painter.fillRect(intersected, gradient)
def draw_handle(self, painter: widgets.StylePainter, handle: HandleStr):
opt = self.get_style_option(handle)
opt.subControls = HANDLE_STYLE
pressed = self.lower_pressed if handle == "lower" else self.upper_pressed
if pressed == HANDLE_STYLE:
opt.activeSubControls = pressed
opt.state |= widgets.Style.StateFlag.State_Sunken
painter.draw_complex_control("slider", opt)
def get_style_option(self, handle: HandleStr) -> widgets.StyleOptionSlider:
option = widgets.StyleOptionSlider()
self.initStyleOption(option)
if handle == "lower":
option.sliderPosition = int(self.lower_pos)
option.sliderValue = int(self.lower_val)
else:
option.sliderPosition = int(self.upper_pos)
option.sliderValue = int(self.upper_val)
return option
def _handle_mouse_press(
self, pos: core.QPointF, control, value: float, handle: HandleStr
):
opt = self.get_style_option(handle)
old_control = control
control = self.style().hitTestComplexControl(
SLIDER_STYLE, opt, pos.toPoint(), self
)
sr = self.style().subControlRect(SLIDER_STYLE, opt, HANDLE_STYLE, self)
if control == HANDLE_STYLE:
self.position = value
self.offset = self.pick(pos.toPoint() - sr.topLeft())
self.last_pressed = handle
self.setSliderDown(True)
self.slider_pressed.emit(handle)
if control != old_control:
self.update(sr)
return control
def _pixel_pos_to_value(self, pos: int) -> int:
opt = widgets.StyleOptionSlider()
self.initStyleOption(opt)
gr = self.style().subControlRect(SLIDER_STYLE, opt, GROOVE_STYLE, self)
sr = self.style().subControlRect(SLIDER_STYLE, opt, HANDLE_STYLE, self)
if self.is_horizontal():
len_slider = sr.width()
slider_min = gr.x()
slider_end = gr.right()
else:
len_slider = sr.height()
slider_min = gr.y()
slider_end = gr.bottom()
return widgets.Style.sliderValueFromPosition(
self.minimum(),
self.maximum(),
pos - slider_min,
slider_end - len_slider + 1 - slider_min,
opt.upsideDown,
)
lower_value = core.Property(
float,
get_lower_value,
set_lower_value,
doc="Lower value",
)
upper_value = core.Property(
float,
get_upper_value,
set_upper_value,
doc="Upper value",
)