I'm trying to create a slider with option for range selection using wxSlider in Python. It has an optional range parameter but the problem is:
SL_SELRANGE: Allows the user to select a range on the slider. Windows only.
And I'm using Linux. I thought I might subclass wxSlider and make it work on Linux, or create a custom widget on my own. The problem is I'm not sure how to go about either option. Any ideas/pointers/pointing me in the right direction would be appreciated.
I tried something like:
range_slider = wx.Slider(parent, wx.ID_ANY, 0, 0, 100, style=wx.SL_HORIZONTAL | wx.SL_LABELS | wx.SL_SELRANGE)
but the "SL_SELRANGE" does nothing on Linux (should provide two handles, to select range).
I'm aware this question is several years old, but even if it's too late to help you, it might help others, as I was going through the same problem recently.
Even in Windows, the wx.SL_SELRANGE
style does not behave as one would expect, creating two independent "thumbs" or handles, which would allow the user to select a range (see this similar question and the documentation). Instead, what it actually does is draw a static band in the trackbar, which does not interact with the single user-controlled thumb. To my knowledge it is not possible to customize the existing wx.Slider
control to have two thumbs, since the control is native to the OS.
In an app I was building I needed to use a control that does what you wanted, but also could not find any good alternatives online. What I ended up doing is creating my own custom RangeSlider
widget, which mimics the behavior and functionality of a regular wx.Slider
, but with two thumbs:
Notice however that the RangeSlider
class handles all the graphics rendering itself and I made it to mimic the Windows 10 look. Therefore the slider appearance will not match the style of a different OS, but it should still work in Linux or OSX. If necessary you could customize the appearance by changing the colors and shapes (all I do is draw rectangles and polygons).
There are some limitations to the widget, it doesn't currently support styles (no ticks or vertical sliders, for example) or validators, but I did implement the wx.EVT_SLIDER
event, so other controls can be notified if the values change (this is what I use to dynamically update the text with the slider values, as the user moves the thumbs).
You can find below the code for a working example (it is also available in this GitHub gist, where I may make improvements over time).
import wx
def fraction_to_value(fraction, min_value, max_value):
return (max_value - min_value) * fraction + min_value
def value_to_fraction(value, min_value, max_value):
return float(value - min_value) / (max_value - min_value)
class SliderThumb:
def __init__(self, parent, value):
self.parent = parent
self.dragged = False
self.mouse_over = False
self.thumb_poly = ((0, 0), (0, 13), (5, 18), (10, 13), (10, 0))
self.thumb_shadow_poly = ((0, 14), (4, 18), (6, 18), (10, 14))
min_coords = [float('Inf'), float('Inf')]
max_coords = [-float('Inf'), -float('Inf')]
for pt in list(self.thumb_poly) + list(self.thumb_shadow_poly):
for i_coord, coord in enumerate(pt):
if coord > max_coords[i_coord]:
max_coords[i_coord] = coord
if coord < min_coords[i_coord]:
min_coords[i_coord] = coord
self.size = (max_coords[0] - min_coords[0],
max_coords[1] - min_coords[1])
self.value = value
self.normal_color = wx.Colour((0, 120, 215))
self.normal_shadow_color = wx.Colour((120, 180, 228))
self.dragged_color = wx.Colour((204, 204, 204))
self.dragged_shadow_color = wx.Colour((222, 222, 222))
self.mouse_over_color = wx.Colour((23, 23, 23))
self.mouse_over_shadow_color = wx.Colour((132, 132, 132))
def GetPosition(self):
min_x = self.GetMin()
max_x = self.GetMax()
parent_size = self.parent.GetSize()
min_value = self.parent.GetMin()
max_value = self.parent.GetMax()
fraction = value_to_fraction(self.value, min_value, max_value)
pos = (fraction_to_value(fraction, min_x, max_x), parent_size[1] / 2 + 1)
return pos
def SetPosition(self, pos):
pos_x = pos[0]
# Limit movement by the position of the other thumb
who_other, other_thumb = self.GetOtherThumb()
other_pos = other_thumb.GetPosition()
if who_other == 'low':
pos_x = max(other_pos[0] + other_thumb.size[0]/2 + self.size[0]/2, pos_x)
else:
pos_x = min(other_pos[0] - other_thumb.size[0]/2 - self.size[0]/2, pos_x)
# Limit movement by slider boundaries
min_x = self.GetMin()
max_x = self.GetMax()
pos_x = min(max(pos_x, min_x), max_x)
fraction = value_to_fraction(pos_x, min_x, max_x)
self.value = fraction_to_value(fraction, self.parent.GetMin(), self.parent.GetMax())
# Post event notifying that position changed
self.PostEvent()
def GetValue(self):
return self.value
def SetValue(self, value):
self.value = value
# Post event notifying that value changed
self.PostEvent()
def PostEvent(self):
event = wx.PyCommandEvent(wx.EVT_SLIDER.typeId, self.parent.GetId())
event.SetEventObject(self.parent)
wx.PostEvent(self.parent.GetEventHandler(), event)
def GetMin(self):
min_x = self.parent.border_width + self.size[0] / 2
return min_x
def GetMax(self):
parent_size = self.parent.GetSize()
max_x = parent_size[0] - self.parent.border_width - self.size[0] / 2
return max_x
def IsMouseOver(self, mouse_pos):
in_hitbox = True
my_pos = self.GetPosition()
for i_coord, mouse_coord in enumerate(mouse_pos):
boundary_low = my_pos[i_coord] - self.size[i_coord] / 2
boundary_high = my_pos[i_coord] + self.size[i_coord] / 2
in_hitbox = in_hitbox and (boundary_low <= mouse_coord <= boundary_high)
return in_hitbox
def GetOtherThumb(self):
if self.parent.thumbs['low'] != self:
return 'low', self.parent.thumbs['low']
else:
return 'high', self.parent.thumbs['high']
def OnPaint(self, dc):
if self.dragged or not self.parent.IsEnabled():
thumb_color = self.dragged_color
thumb_shadow_color = self.dragged_shadow_color
elif self.mouse_over:
thumb_color = self.mouse_over_color
thumb_shadow_color = self.mouse_over_shadow_color
else:
thumb_color = self.normal_color
thumb_shadow_color = self.normal_shadow_color
my_pos = self.GetPosition()
# Draw thumb shadow (or anti-aliasing effect)
dc.SetBrush(wx.Brush(thumb_shadow_color, style=wx.BRUSHSTYLE_SOLID))
dc.SetPen(wx.Pen(thumb_shadow_color, width=1, style=wx.PENSTYLE_SOLID))
dc.DrawPolygon(points=self.thumb_shadow_poly,
xoffset=my_pos[0] - self.size[0]/2,
yoffset=my_pos[1] - self.size[1]/2)
# Draw thumb itself
dc.SetBrush(wx.Brush(thumb_color, style=wx.BRUSHSTYLE_SOLID))
dc.SetPen(wx.Pen(thumb_color, width=1, style=wx.PENSTYLE_SOLID))
dc.DrawPolygon(points=self.thumb_poly,
xoffset=my_pos[0] - self.size[0] / 2,
yoffset=my_pos[1] - self.size[1] / 2)
class RangeSlider(wx.Panel):
def __init__(self, parent, id=wx.ID_ANY, lowValue=None, highValue=None, minValue=0, maxValue=100,
pos=wx.DefaultPosition, size=wx.DefaultSize, style=wx.SL_HORIZONTAL, validator=wx.DefaultValidator,
name='rangeSlider'):
if style != wx.SL_HORIZONTAL:
raise NotImplementedError('Styles not implemented')
if validator != wx.DefaultValidator:
raise NotImplementedError('Validator not implemented')
super().__init__(parent=parent, id=id, pos=pos, size=size, name=name)
self.SetMinSize(size=(max(50, size[0]), max(26, size[1])))
if minValue > maxValue:
minValue, maxValue = maxValue, minValue
self.min_value = minValue
self.max_value = maxValue
if lowValue is None:
lowValue = self.min_value
if highValue is None:
highValue = self.max_value
if lowValue > highValue:
lowValue, highValue = highValue, lowValue
lowValue = max(lowValue, self.min_value)
highValue = min(highValue, self.max_value)
self.border_width = 8
self.thumbs = {
'low': SliderThumb(parent=self, value=lowValue),
'high': SliderThumb(parent=self, value=highValue)
}
self.thumb_width = self.thumbs['low'].size[0]
# Aesthetic definitions
self.slider_background_color = wx.Colour((231, 234, 234))
self.slider_outline_color = wx.Colour((214, 214, 214))
self.selected_range_color = wx.Colour((0, 120, 215))
self.selected_range_outline_color = wx.Colour((0, 120, 215))
# Bind events
self.Bind(wx.EVT_LEFT_DOWN, self.OnMouseDown)
self.Bind(wx.EVT_LEFT_UP, self.OnMouseUp)
self.Bind(wx.EVT_MOTION, self.OnMouseMotion)
self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self.OnMouseLost)
self.Bind(wx.EVT_ENTER_WINDOW, self.OnMouseEnter)
self.Bind(wx.EVT_LEAVE_WINDOW, self.OnMouseLeave)
self.Bind(wx.EVT_PAINT, self.OnPaint)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
self.Bind(wx.EVT_SIZE, self.OnResize)
def Enable(self, enable=True):
super().Enable(enable)
self.Refresh()
def Disable(self):
super().Disable()
self.Refresh()
def SetValueFromMousePosition(self, click_pos):
for thumb in self.thumbs.values():
if thumb.dragged:
thumb.SetPosition(click_pos)
def OnMouseDown(self, evt):
if not self.IsEnabled():
return
click_pos = evt.GetPosition()
for thumb in self.thumbs.values():
if thumb.IsMouseOver(click_pos):
thumb.dragged = True
thumb.mouse_over = False
break
self.SetValueFromMousePosition(click_pos)
self.CaptureMouse()
self.Refresh()
def OnMouseUp(self, evt):
if not self.IsEnabled():
return
self.SetValueFromMousePosition(evt.GetPosition())
for thumb in self.thumbs.values():
thumb.dragged = False
if self.HasCapture():
self.ReleaseMouse()
self.Refresh()
def OnMouseLost(self, evt):
for thumb in self.thumbs.values():
thumb.dragged = False
thumb.mouse_over = False
self.Refresh()
def OnMouseMotion(self, evt):
if not self.IsEnabled():
return
refresh_needed = False
mouse_pos = evt.GetPosition()
if evt.Dragging() and evt.LeftIsDown():
self.SetValueFromMousePosition(mouse_pos)
refresh_needed = True
else:
for thumb in self.thumbs.values():
old_mouse_over = thumb.mouse_over
thumb.mouse_over = thumb.IsMouseOver(mouse_pos)
if old_mouse_over != thumb.mouse_over:
refresh_needed = True
if refresh_needed:
self.Refresh()
def OnMouseEnter(self, evt):
if not self.IsEnabled():
return
mouse_pos = evt.GetPosition()
for thumb in self.thumbs.values():
if thumb.IsMouseOver(mouse_pos):
thumb.mouse_over = True
self.Refresh()
break
def OnMouseLeave(self, evt):
if not self.IsEnabled():
return
for thumb in self.thumbs.values():
thumb.mouse_over = False
self.Refresh()
def OnResize(self, evt):
self.Refresh()
def OnPaint(self, evt):
w, h = self.GetSize()
# BufferedPaintDC should reduce flickering
dc = wx.BufferedPaintDC(self)
background_brush = wx.Brush(self.GetBackgroundColour(), wx.SOLID)
dc.SetBackground(background_brush)
dc.Clear()
# Draw slider
track_height = 12
dc.SetPen(wx.Pen(self.slider_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.slider_background_color, style=wx.BRUSHSTYLE_SOLID))
dc.DrawRectangle(self.border_width, h/2 - track_height/2, w - 2 * self.border_width, track_height)
# Draw selected range
if self.IsEnabled():
dc.SetPen(wx.Pen(self.selected_range_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.selected_range_color, style=wx.BRUSHSTYLE_SOLID))
else:
dc.SetPen(wx.Pen(self.slider_outline_color, width=1, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(self.slider_outline_color, style=wx.BRUSHSTYLE_SOLID))
low_pos = self.thumbs['low'].GetPosition()[0]
high_pos = self.thumbs['high'].GetPosition()[0]
dc.DrawRectangle(low_pos, h / 2 - track_height / 4, high_pos - low_pos, track_height / 2)
# Draw thumbs
for thumb in self.thumbs.values():
thumb.OnPaint(dc)
evt.Skip()
def OnEraseBackground(self, evt):
# This should reduce flickering
pass
def GetValues(self):
return self.thumbs['low'].value, self.thumbs['high'].value
def SetValues(self, lowValue, highValue):
if lowValue > highValue:
lowValue, highValue = highValue, lowValue
lowValue = max(lowValue, self.min_value)
highValue = min(highValue, self.max_value)
self.thumbs['low'].SetValue(lowValue)
self.thumbs['high'].SetValue(highValue)
self.Refresh()
def GetMax(self):
return self.max_value
def GetMin(self):
return self.min_value
def SetMax(self, maxValue):
if maxValue < self.min_value:
maxValue = self.min_value
_, old_high = self.GetValues()
if old_high > maxValue:
self.thumbs['high'].SetValue(maxValue)
self.max_value = maxValue
self.Refresh()
def SetMin(self, minValue):
if minValue > self.max_value:
minValue = self.max_value
old_low, _ = self.GetValues()
if old_low < minValue:
self.thumbs['low'].SetValue(minValue)
self.min_value = minValue
self.Refresh()
class TestFrame(wx.Frame):
def __init__(self):
wx.Frame.__init__(self, None, -1, 'Range Slider Demo', size=(300, 100))
panel = wx.Panel(self)
b = 6
vbox = wx.BoxSizer(orient=wx.VERTICAL)
vbox.Add(wx.StaticText(parent=panel, label='Custom Range Slider:'), flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.rangeslider = RangeSlider(parent=panel, lowValue=20, highValue=80, minValue=0, maxValue=100,
size=(300, 26))
self.rangeslider.Bind(wx.EVT_SLIDER, self.rangeslider_changed)
vbox.Add(self.rangeslider, proportion=1, flag=wx.EXPAND | wx.ALL, border=b)
self.rangeslider_static = wx.StaticText(panel)
vbox.Add(self.rangeslider_static, flag=wx.ALIGN_LEFT | wx.ALL, border=b)
vbox.Add(wx.StaticText(parent=panel, label='Regular Slider with wx.SL_SELRANGE style:'),
flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.slider = wx.Slider(parent=panel, style=wx.SL_SELRANGE)
self.slider.SetSelection(20, 40)
self.slider.Bind(wx.EVT_SLIDER, self.slider_changed)
vbox.Add(self.slider, proportion=1, flag=wx.EXPAND | wx.ALL, border=b)
self.slider_static = wx.StaticText(panel)
vbox.Add(self.slider_static, flag=wx.ALIGN_LEFT | wx.ALL, border=b)
self.button_toggle = wx.Button(parent=panel, label='Disable')
self.button_toggle.Bind(wx.EVT_BUTTON, self.toggle_slider_enable)
vbox.Add(self.button_toggle, flag=wx.ALIGN_CENTER | wx.ALL, border=b)
panel.SetSizerAndFit(vbox)
box = wx.BoxSizer()
box.Add(panel, proportion=1, flag=wx.EXPAND)
self.SetSizerAndFit(box)
def slider_changed(self, evt):
obj = evt.GetEventObject()
val = obj.GetValue()
self.slider_static.SetLabel('Value: {}'.format(val))
def rangeslider_changed(self, evt):
obj = evt.GetEventObject()
lv, hv = obj.GetValues()
self.rangeslider_static.SetLabel('Low value: {:.0f}, High value: {:.0f}'.format(lv, hv))
def toggle_slider_enable(self, evt):
if self.button_toggle.GetLabel() == 'Disable':
self.slider.Enable(False)
self.rangeslider.Enable(False)
self.button_toggle.SetLabel('Enable')
else:
self.slider.Enable(True)
self.rangeslider.Enable(True)
self.button_toggle.SetLabel('Disable')
def main():
app = wx.App()
TestFrame().Show()
app.MainLoop()
if __name__ == "__main__":
main()
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With