#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""A class for getting numeric or categorical ratings, e.g., a 1-to-7 scale."""
# Part of the PsychoPy library
# Copyright (C) 2015 Jonathan Peirce
# Distributed under the terms of the GNU General Public License (GPL).
import copy
import numpy as np
from psychopy import core, logging, event, layout
from psychopy.tools import arraytools, stimulustools as stt
from .basevisual import MinimalStim, WindowMixin, ColorMixin, BaseVisualStim
from .rect import Rect
from .grating import GratingStim
from .elementarray import ElementArrayStim
from .circle import Circle
from .shape import ShapeStim
from . import TextBox2
from ..tools.attributetools import logAttrib, setAttribute, attributeSetter
from ..constants import FINISHED, STARTED, NOT_STARTED
# Set to True to make borders visible for debugging
debug = False
[docs]class Slider(MinimalStim, WindowMixin, ColorMixin):
"""A class for obtaining ratings, e.g., on a 1-to-7 or categorical scale.
A simpler alternative to RatingScale, to be customised with code rather
than with arguments.
A RatingScale instance is a re-usable visual object having a ``draw()``
method, with customizable appearance and response options. ``draw()``
displays the rating scale, handles the subject's mouse or key responses,
and updates the display. When the subject accepts a selection,
``.noResponse`` goes ``False`` (i.e., there is a response).
You can call the ``getRating()`` method anytime to get a rating,
``getRT()`` to get the decision time, or ``getHistory()`` to obtain
the entire set of (rating, RT) pairs.
For other examples see Coder Demos -> stimuli -> ratingsNew.py.
:Authors:
- 2018: Jon Peirce
"""
def __init__(self,
win,
ticks=(1, 2, 3, 4, 5),
labels=None,
startValue=None,
pos=(0, 0),
size=None,
units=None,
flip=False,
ori=0,
style='rating', styleTweaks=[],
granularity=0,
readOnly=False,
labelColor='White',
markerColor='Red',
lineColor='White',
colorSpace='rgb',
opacity=None,
font='Helvetica Bold',
depth=0,
name=None,
labelHeight=None,
labelWrapWidth=None,
autoDraw=False,
autoLog=True,
# Synonyms
color=False,
fillColor=False,
borderColor=False):
"""
Parameters
----------
win : psychopy.visual.Window
Into which the scale will be rendered
ticks : list or tuple
A set of values for tick locations. If given a list of numbers then
these determine the locations of the ticks (the first and last
determine the endpoints and the rest are spaced according to
their values between these endpoints.
labels : a list or tuple
The text to go with each tick (or spaced evenly across the ticks).
If you give 3 labels but 5 tick locations then the end and middle
ticks will be given labels. If the labels can't be distributed
across the ticks then an error will be raised. If you want an
uneven distribution you should include a list matching the length
of ticks but with some values set to None
pos : XY pair (tuple, array or list)
size : w,h pair (tuple, array or list)
The size for the scale defines the area taken up by the line and
the ticks.
This also controls whether the scale is horizontal or vertical.
units : the units to interpret the pos and size
flip : bool
By default the labels will be below or left of the line. This
puts them above (or right)
granularity : int or float
The smallest valid increments for the scale. 0 gives a continuous
(e.g. "VAS") scale. 1 gives a traditional likert scale. Something
like 0.1 gives a limited fine-grained scale.
labelColor / color :
Color of the labels according to the color space
markerColor / fillColor :
Color of the marker according to the color space
lineColor / borderColor :
Color of the line and ticks according to the color space
font : font name
autodraw :
depth :
name :
autoLog :
"""
# what local vars are defined (these are the init params) for use by
# __repr__
self._initParams = dir()
super(Slider, self).__init__(name=name, autoLog=False)
self.win = win
if ticks is None:
self.ticks = None
else:
self.ticks = np.array(ticks)
self.labels = labels
# Set pos and size via base method as objects don't yet exist to layout
self.units = units
WindowMixin.pos.fset(self, pos)
if size is None:
size = layout.Size((1, 0.1), 'height', self.win)
WindowMixin.size.fset(self, size)
# Set multiplier and additions to each component's size
self._markerSizeMultiplier = (1, 1)
self._markerSizeAddition = (0, 0)
self._lineSizeMultiplier = (1, 1)
self._lineSizeAddition = (0, 0)
self._tickSizeMultiplier = (1, 1)
self._tickSizeAddition = (0, 0)
# Allow styles to force alignment/anchor for labels
self._forceLabelAnchor = None
self.granularity = granularity
self.colorSpace = colorSpace
self.color = color if color is not False else labelColor
self.fillColor = fillColor if fillColor is not False else markerColor
self.borderColor = borderColor if borderColor is not False else lineColor
self.opacity = opacity
self.font = font
self.autoDraw = autoDraw
self.depth = depth
self.name = name
self.autoLog = autoLog
self.readOnly = readOnly
self.ori = ori
self.flip = flip
self.rt = None
self.history = []
self.marker = None
self.line = None
self.tickLines = None
self.labelWrapWidth = labelWrapWidth
self.labelHeight = labelHeight or min(self.size) / 2
self._updateMarkerPos = True
self._dragging = False
self.mouse = event.Mouse(win=win)
self._mouseStateClick = None # so we can rule out long click probs
self._mouseStateXY = None # so we can rule out long click probs
self.validArea = None
# Create elements
self._createElements()
self.styleTweaks = []
self.style = style
self.styleTweaks += styleTweaks
self._layout()
# some things must wait until elements created
self.contrast = 1.0
self.startValue = self.markerPos = startValue
# set autoLog (now that params have been initialised)
self.autoLog = autoLog
if autoLog:
logging.exp("Created %s = %s" % (self.name, repr(self)))
self.status = NOT_STARTED
self.responseClock = core.Clock()
def __repr__(self, complete=False):
return self.__str__(complete=complete) # from MinimalStim
@property
def _tickL(self):
"""The length of the line (in the size units)
"""
return min(self.extent)
@property
def units(self):
return WindowMixin.units.fget(self)
@units.setter
def units(self, value):
WindowMixin.units.fset(self, value)
if hasattr(self, "line"):
self.line.units = value
if hasattr(self, "marker"):
self.marker.units = value
if hasattr(self, "tickLines"):
self.tickLines.units = value
if hasattr(self, "labelObjs"):
for label in self.labelObjs:
label.units = value
if hasattr(self, "validArea"):
self.validArea.units = value
@property
def pos(self):
return WindowMixin.pos.fget(self)
@pos.setter
def pos(self, value):
WindowMixin.pos.fset(self, value)
self._layout()
[docs] def setPos(self, newPos, operation='', log=None):
BaseVisualStim.setPos(self, newPos, operation=operation, log=log)
[docs] def setOri(self, newOri, operation='', log=None):
BaseVisualStim.setOri(self, newOri, operation=operation, log=log)
@property
def size(self):
return WindowMixin.size.fget(self)
@size.setter
def size(self, value):
WindowMixin.size.fset(self, value)
self._layout()
[docs] def setSize(self, newSize, operation='', units=None, log=None):
BaseVisualStim.setSize(self, newSize, operation=operation, units=units, log=log)
@property
def horiz(self):
"""(readonly) determines from self.size whether the scale is horizontal"""
return self.extent[0] > self.extent[1]
@property
def categorical(self):
"""(readonly) determines from labels and ticks whether the slider is categorical"""
return self.ticks is None or self.style == "radio"
@property
def extent(self):
"""
The distance from the leftmost point on the slider to the rightmost point, and from the highest
point to the lowest.
"""
# Get orientation (theta) and inverse orientation (atheta) in radans
theta = np.radians(self.ori)
atheta = np.radians(90-self.ori)
# Calculate adjacent sides to get vertical extent
A1 = abs(np.cos(theta) * self.size[1])
A2 = abs(np.cos(atheta) * self.size[0])
# Calculate opposite sides to get horizontal extent
O1 = abs(np.sin(theta) * self.size[1])
O2 = abs(np.sin(atheta) * self.size[0])
# Return extent
return O1 + O2, A1 + A2
@extent.setter
def extent(self, value):
self._extent = layout.Size(self.extent, self.units, self.win)
@property
def flip(self):
if hasattr(self, "_flip"):
return self._flip
@flip.setter
def flip(self, value):
self._flip = value
@property
def opacity(self):
BaseVisualStim.opacity.fget(self)
@opacity.setter
def opacity(self, value):
BaseVisualStim.opacity.fset(self, value)
self.fillColor = self._fillColor.copy()
self.borderColor = self._borderColor.copy()
self.foreColor = self._foreColor.copy()
[docs] def setOpacity(self, newOpacity, operation='', log=None):
BaseVisualStim.setOpacity(self, newOpacity, operation=operation, log=log)
[docs] def updateOpacity(self):
BaseVisualStim.updateOpacity(self)
@property
def labelHeight(self):
if hasattr(self, "_labelHeight"):
return getattr(self._labelHeight, self.units)[1]
@labelHeight.setter
def labelHeight(self, value):
if isinstance(value, layout.Vector):
# If given a Size, use it
self._labelHeight = value
else:
# Otherwise, convert to a Size object
self._labelHeight = layout.Size([None, value], units=self.units, win=self.win)
@property
def labelWrapWidth(self):
if hasattr(self, "_labelWrapWidth"):
return getattr(self._labelWrapWidth, self.units)[0]
@labelWrapWidth.setter
def labelWrapWidth(self, value):
if value is None:
pass
elif isinstance(value, layout.Vector):
# If given a Size, use it
self._labelWrapWidth = value
else:
# Otherwise, convert to a Size object
self._labelWrapWidth = layout.Size([value, None], units=self.units, win=self.win)
@property
def foreColor(self):
ColorMixin.foreColor.fget(self)
@foreColor.setter
def foreColor(self, value):
ColorMixin.foreColor.fset(self, value)
# Set color of each label
if hasattr(self, 'labelObjs'):
for lbl in self.labelObjs:
lbl.color = self._foreColor.copy()
@property
def labelColor(self):
"""
Synonym of Slider.foreColor
"""
return self.foreColor
@labelColor.setter
def labelColor(self, value):
self.foreColor = value
@property
def fillColor(self):
ColorMixin.fillColor.fget(self)
@fillColor.setter
def fillColor(self, value):
ColorMixin.fillColor.fset(self, value)
# Set color of marker
if hasattr(self, 'marker'):
self.marker.fillColor = self._fillColor.copy()
@property
def markerColor(self):
"""
Synonym of Slider.fillColor
"""
return self.fillColor
@markerColor.setter
def markerColor(self, value):
self.fillColor = value
@property
def borderColor(self):
ColorMixin.borderColor.fget(self)
@borderColor.setter
def borderColor(self, value):
ColorMixin.borderColor.fset(self, value)
# Set color of lines
if hasattr(self, 'line'):
if self.style not in ["scrollbar"]: # Scrollbar doesn't have an outline
self.line.color = self._borderColor.copy()
self.line.fillColor = self._borderColor.copy()
if self.style in ["slider", "scrollbar"]: # Slider and scrollbar need translucent fills
self.line._fillColor.alpha *= 0.2
if hasattr(self, 'tickLines'):
self.tickLines.colors = self._borderColor.copy()
self.tickLines.opacities = self._borderColor.alpha
[docs] def reset(self):
"""Resets the slider to its starting state (so that it can be restarted
on each trial with a new stimulus)
"""
self.rating = None # this is reset to None, whatever the startValue
self.markerPos = self.startValue
self.history = []
self.rt = None
self.responseClock.reset()
self.status = NOT_STARTED
def _createElements(self):
# Refresh extent
self.extent = self.extent
# Make line
self._getLineParams()
self.line = Rect(
win=self.win,
pos=self.lineParams['pos'], size=self.lineParams['size'], units=self.units,
fillColor=self._borderColor.copy(), colorSpace=self.colorSpace,
autoLog=False
)
# Make ticks
self._getTickParams()
self.tickLines = ElementArrayStim(
win=self.win,
xys=self.tickParams['xys'], sizes=self.tickParams['sizes'], units=self.units,
nElements=len(self.ticks), elementMask=None, sfs=0,
colors=self._borderColor.copy(), opacities=self._borderColor.alpha, colorSpace=self.colorSpace,
autoLog=False
)
# Make labels
self.labelObjs = []
if self.labels is not None:
self._getLabelParams()
for n, label in enumerate(self.labels):
# Skip blank labels
if label is None:
continue
# Create textbox for each label
obj = TextBox2(
self.win, label, font=self.font,
pos=self.labelParams['pos'][n], size=self.labelParams['size'][n], padding=self.labelParams['padding'][n], units=self.units,
anchor=self.labelParams['anchor'][n], alignment=self.labelParams['alignment'][n],
color=self._foreColor.copy(), fillColor=None, colorSpace=self.colorSpace,
borderColor="red" if debug else None,
letterHeight=self.labelHeight,
autoLog=False
)
self.labelObjs.append(obj)
# Make marker
self._getMarkerParams()
self.marker = ShapeStim(
self.win,
vertices="circle",
pos=self.markerParams['pos'], size=self.markerParams['size'], units=self.units,
fillColor=self._fillColor, lineColor=None,
autoLog=False
)
# create a rectangle to check for clicks
self._getHitboxParams()
self.validArea = Rect(
self.win,
pos=self.hitboxParams['pos'], size=self.hitboxParams['size'], units=self.units,
fillColor=None, lineColor="red" if debug else None,
autoLog=False
)
def _layout(self):
# Refresh style
self.style = self.style
# Refresh extent
self.extent = self.extent
# Get marker params
self._getMarkerParams()
# Apply marker params
self.marker.units = self.units
for param, value in self.markerParams.items():
setattr(self.marker, param, value)
# Get line params
self._getLineParams()
# Apply line params
self.line.units = self.units
for param, value in self.lineParams.items():
setattr(self.line, param, value)
# Get tick params
self._getTickParams()
# Apply tick params
self.tickLines.units = self.units
for param, value in self.tickParams.items():
setattr(self.tickLines, param, value)
# Get label params
self._getLabelParams()
# Apply label params
for n, obj in enumerate(self.labelObjs):
obj.units = self.units
for param, value in self.labelParams.items():
setattr(obj, param, value[n])
# Get hitbox params
self._getHitboxParams()
# Apply hitbox params
self.validArea.units = self.units
for param, value in self.hitboxParams.items():
setattr(self.validArea, param, value)
def _ratingToPos(self, rating):
# Get ticks or substitute
if self.ticks is not None:
ticks = self.ticks
else:
ticks = [0, len(self.labels)]
# If rating is a label, convert to an index
if isinstance(rating, str) and rating in self.labels:
rating = self.labels.index(rating)
# Reshape rating to handle multiple values
rating = np.array(rating)
rating = rating.reshape((-1, 1))
rating = np.hstack((rating, rating))
# Adjust to scale bottom
magDelta = rating - ticks[0]
# Adjust to scale magnitude
delta = magDelta / (ticks[-1] - ticks[0])
# Adjust to scale size
delta = self._extent.pix * (delta - 0.5)
# Adjust to scale pos
pos = delta + self._pos.pix
# Replace irrelevant value according to orientation
pos[:, int(self.horiz)] = self._pos.pix[int(self.horiz)]
# Convert to native units
return getattr(layout.Position(pos, units="pix", win=self.win), self.units)
def _posToRating(self, pos):
# Get ticks or substitute
if self.ticks is not None:
ticks = self.ticks
else:
ticks = [0, 1]
# Get in pix
pos = layout.Position(pos, units=self.win.units, win=self.win).pix
# Get difference from scale pos
delta = pos - self._pos.pix
# Adjust to scale size
delta = delta / self._extent.pix + 0.5
# Adjust to scale magnitude
magDelta = delta * (ticks[-1] - ticks[0])
# Adjust to scale bottom
rating = magDelta + ticks[0]
# Return relevant value according to orientation
return rating[1-int(self.horiz)]
[docs] def _getLineParams(self):
"""
Calculates location and size of the line based on own location and size
"""
# Store line details
self.lineParams = {
'units': self.units,
'pos': self.pos,
'size': self._extent * np.array(self._lineSizeMultiplier) + layout.Size(self._lineSizeAddition, self.units, self.win)
}
[docs] def _getMarkerParams(self):
"""
Calculates location and size of marker based on own location and size
"""
# Calculate pos
pos = self._ratingToPos(self.rating or 0)
# Get size (and correct for norm)
size = layout.Size([min(self._size.pix)]*2, 'pix', self.win)
# Store marker details
self.markerParams = {
'units': self.units,
'pos': pos,
'size': size * np.array(self._markerSizeMultiplier) + layout.Size(self._markerSizeAddition, self.units, self.win),
}
[docs] def _getTickParams(self):
""" Calculates the locations of the line, tickLines and labels from
the rating info
"""
# If categorical, create tick values from labels
if self.categorical:
if self.labels is None:
self.ticks = np.arange(5)
else:
self.ticks = np.arange(len(self.labels))
self.granularity = 1.0
# Calculate positions
xys = self._ratingToPos(self.ticks)
# Get size (and correct for norm)
size = layout.Size([min(self._extent.pix)]*2, 'pix', self.win)
# Store tick details
self.tickParams = {
'units': self.units,
'xys': xys,
'sizes': np.tile(
getattr(size, self.units) * np.array(self._tickSizeMultiplier) + np.array(self._tickSizeAddition),
(len(self.ticks), 1)),
}
def _getLabelParams(self):
if self.labels is None:
return
# Get number of labels now for convenience
n = len(self.labels)
# Get coords of slider edges
top = self.pos[1] + self.extent[1] / 2
bottom = self.pos[1] - self.extent[1] / 2
left = self.pos[0] - self.extent[0] / 2
right = self.pos[0] + self.extent[0] / 2
# Work out where labels are relative to line
w = self.labelWrapWidth
if self.horiz: # horizontal
# Always centered horizontally
anchorHoriz = alignHoriz = 'center'
# Width as fraction of size, height starts at double slider
if w is None:
w = self.extent[0] / len(self.ticks)
h = self.extent[1] * 3
# Evenly spaced, constant y
x = np.linspace(left, right, num=n)
x = arraytools.snapto(x, points=self.tickParams['xys'][:, 0])
y = [self.pos[1]] * n
# Padding applied on vertical
paddingHoriz = 0
paddingVert = (self._tickL + self.labelHeight) / 2
# Vertical align/anchor depend on flip
if not self.flip:
# Labels below means anchor them from the top
anchorVert = alignVert = 'top'
else:
# Labels on top means anchor them from below
anchorVert = alignVert = 'bottom'
# If style tells us to force label anchor, force it
if self._forceLabelAnchor is not None:
anchorVert = alignVert = self._forceLabelAnchor
else: # vertical
# Always centered vertically
anchorVert = alignVert = 'center'
# Height as fraction of size, width starts at double slider
h = self.extent[1] / len(self.ticks)
if w is None:
w = self.extent[0] * 3
# Evenly spaced and clipped to ticks, constant x
y = np.linspace(bottom, top, num=n)
y = arraytools.snapto(y, points=self.tickParams['xys'][:, 1])
x = [self.pos[0]] * n
# Padding applied on horizontal
paddingHoriz = (self._tickL + self.labelHeight) / 2
paddingVert = 0
# Horizontal align/anchor depend on flip
if not self.flip:
# Labels left means anchor them from the right
anchorHoriz = alignHoriz = 'right'
else:
# Labels right means anchor them from the left
anchorHoriz = alignHoriz = 'left'
# If style tells us to force label anchor, force it
if self._forceLabelAnchor is not None:
anchorHoriz = alignHoriz = self._forceLabelAnchor
# Store label details
self.labelParams = {
'units': (self.units,) * n,
'pos': np.vstack((x, y)).transpose(None),
'size': np.tile((w, h), (n, 1)),
'padding': np.tile((paddingHoriz, paddingVert), (n, 1)),
'anchor': np.tile((anchorHoriz, anchorVert), (n, 1)),
'alignment': np.tile((alignHoriz, alignVert), (n, 1))
}
[docs] def _getHitboxParams(self):
"""
Calculates hitbox size and pos from own size and pos
"""
# Get pos
pos = self.pos
# Get size
size = self._extent * 1.1
# Store hitbox details
self.hitboxParams = {
'units': self.units,
'pos': pos,
'size': size,
}
[docs] def _granularRating(self, rating):
"""Handle granularity for the rating"""
if rating is not None:
if self.categorical:
# If this is a categorical slider, snap to closest tick
deltas = np.absolute(np.asarray(self.ticks) - rating)
i = np.argmin(deltas)
rating = self.ticks[i]
elif self.granularity > 0:
rating = round(rating / self.granularity) * self.granularity
rating = round(rating, 8) # or gives 1.9000000000000001
rating = max(rating, self.ticks[0])
rating = min(rating, self.ticks[-1])
return rating
@property
def rating(self):
if hasattr(self, "_rating"):
return self._rating
@rating.setter
def rating(self, rating):
"""The most recent rating from the participant or None.
Note that the position of the marker can be set using current without
looking like a change in the marker position"""
rating = self._granularRating(rating)
self.markerPos = rating
if self.categorical and (rating is not None):
rating = self.labels[int(round(rating))]
self._rating = rating
@property
def value(self):
"""Synonymous with .rating"""
return self.rating
@value.setter
def value(self, val):
self.rating = val
@attributeSetter
def ticks(self, value):
if isinstance(value, (list, tuple, np.ndarray)):
# make sure all values are numeric
for i, subval in enumerate(value):
if isinstance(subval, str):
if subval in self.labels:
# if it's a label name, get its index
value[i] = self.labels.index(subval)
elif subval.isnumeric():
# if it's a stringified number, make it a float
value[i] = float(subval)
else:
# otherwise, use its index within the array
value[i] = i
self.__dict__['ticks'] = value
@attributeSetter
def markerPos(self, rating):
"""The position on the scale where the marker should be. Note that
this does not alter the value of the reported rating, only its visible
display.
Also note that this position is in scale units, not in coordinates"""
rating = self._granularRating(rating)
if ('markerPos' not in self.__dict__ or not np.all(
self.__dict__['markerPos'] == rating)):
self.__dict__['markerPos'] = rating
self._updateMarkerPos = True
[docs] def recordRating(self, rating, rt=None, log=None):
"""Sets the current rating value
"""
rating = self._granularRating(rating)
setAttribute(self, attrib='rating', value=rating, operation='', log=log)
if rt is None:
self.rt = self.responseClock.getTime()
else:
self.rt = rt
self.history.append((rating, self.rt))
self._updateMarkerPos = True
[docs] def getRating(self):
"""Get the current value of rating (or None if no response yet)
"""
return self.rating
[docs] def getRT(self):
"""Get the RT for most recent rating (or None if no response yet)
"""
return self.rt
[docs] def getMarkerPos(self):
"""Get the current marker position (or None if no response yet)
"""
return self.markerPos
[docs] def setMarkerPos(self, rating):
"""Set the current marker position (or None if no response yet)
Parameters
----------
rating : int or float
The rating on the scale where we want to set the marker
"""
if self._updateMarkerPos:
self.marker.pos = self._ratingToPos(rating)
self.markerPos = rating
self._updateMarkerPos = False
[docs] def draw(self):
"""Draw the Slider, with all its constituent elements on this frame
"""
self.getMouseResponses()
if debug:
self.validArea.draw()
self.line.draw()
self.tickLines.draw()
if self.markerPos is not None:
if self._updateMarkerPos:
self.marker.pos = self._ratingToPos(self.markerPos)
self._updateMarkerPos = False
self.marker.draw()
for label in self.labelObjs:
label.draw()
# we started drawing to reset clock on flip
if self.status == NOT_STARTED:
self.win.callOnFlip(self.responseClock.reset)
self.status = STARTED
[docs] def getHistory(self):
"""Return a list of the subject's history as (rating, time) tuples.
The history can be retrieved at any time, allowing for continuous
ratings to be obtained in real-time. Both numerical and categorical
choices are stored automatically in the history.
"""
return self.history
[docs] def setReadOnly(self, value=True, log=None):
"""When the rating scale is read only no responses can be made and the
scale contrast is reduced
Parameters
----------
value : bool (True)
The value to which we should set the readOnly flag
log : bool or None
Force the autologging to occur or leave as default
"""
setAttribute(self, 'readOnly', value, log)
if value == True:
self.contrast = 0.5
else:
self.contrast = 1.0
@attributeSetter
def contrast(self, contrast):
"""Set all elements of the Slider (labels, ticks, line) to a contrast
Parameters
----------
contrast
"""
self.marker.contrast = contrast
self.line.contrast = contrast
self.tickLines.contrasts = contrast
for label in self.labelObjs:
label.contrast = contrast
[docs] def getMouseResponses(self):
"""Instructs the rating scale to check for valid mouse responses.
This is usually done during the draw() method but can be done by the
user as well at any point in time. The rating will be returned but
will ALSO automatically be set as the current rating response.
While the mouse button is down we will alter self.markerPos
but don't set a value for self.rating until button comes up
Returns
----------
A rating value or None
"""
if self.readOnly:
return
click = bool(self.mouse.getPressed()[0])
xy = self.mouse.getPos()
if click:
# Update current but don't set Rating (mouse is still down)
# Dragging has to start inside a "valid" area (i.e., on the
# slider), but may continue even if the mouse moves away from
# the slider, as long as the mouse button is not released.
if (self.validArea.contains(self.mouse, units=self.units) or
self._dragging):
self.markerPos = self._posToRating(xy) # updates marker
self._dragging = True
self._updateMarkerPos = True
else: # mouse is up - check if it *just* came up
if self._dragging:
self._dragging = False
if self.markerPos is not None:
self.recordRating(self.markerPos)
return self.markerPos
else:
# is up and was already up - move along
return None
self._mouseStateXY = xy
# Overload color setters so they set sub-components
@property
def foreColor(self):
ColorMixin.foreColor.fget(self)
@foreColor.setter
def foreColor(self, value):
ColorMixin.foreColor.fset(self, value)
# Set color for all labels
if hasattr(self, "labelObjs"):
for obj in self.labelObjs:
obj.color = self._foreColor.copy()
@property
def fillColor(self):
ColorMixin.fillColor.fget(self)
@fillColor.setter
def fillColor(self, value):
ColorMixin.fillColor.fset(self, value)
# Set color for marker
if hasattr(self, "marker"):
self.marker.fillColor = self._fillColor.copy()
@property
def borderColor(self):
ColorMixin.borderColor.fget(self)
@borderColor.setter
def borderColor(self, value):
ColorMixin.borderColor.fset(self, value)
# Set color for lines
if hasattr(self, "line"):
self.line.color = self._borderColor.copy()
if hasattr(self, "tickLines"):
self.tickLines.colors = self._borderColor.copy()
knownStyles = stt.sliderStyles
legacyStyles = []
knownStyleTweaks = stt.sliderStyleTweaks
legacyStyleTweaks = ['whiteOnBlack']
@property
def style(self):
if hasattr(self, "_style"):
return self._style
@style.setter
def style(self, style):
"""Sets some predefined styles or use these to create your own.
If you fancy creating and including your own styles that would be great!
Parameters
----------
style: string
Known styles currently include:
'rating': the marker is a circle
'slider': looks more like an application slider control
'whiteOnBlack': a sort of color-inverse rating scale
'scrollbar': looks like a scrollbar for a window
Styles cannot be combined in a list - they are discrete
"""
self._style = style
# Legacy: If given a list (as was once the case), take the first style
if isinstance(style, (list, tuple)):
styles = style
style = "rating"
for val in styles:
# If list contains a style, use it
if val in self.knownStyles + self.legacyStyles:
style = val
# Apply any tweaks
if val in self.knownStyleTweaks + self.legacyStyleTweaks:
self.styleTweaks += val
if style == 'rating' or style is None:
# Narrow line
self.line.opacity = 1
self._lineSizeAddition = (0, 0)
if self.horiz:
self._lineSizeMultiplier = (1, 0.1)
else:
self._lineSizeMultiplier = (0.1, 1)
# 1:1 circular markers
self.marker.vertices = "circle"
self._markerSizeMultiplier = (1, 1)
self._markerSizeAddition = (0, 0)
# Narrow rectangular ticks
self.tickLines.elementMask = None
self._tickSizeAddition = (0, 0)
if self.horiz:
self._tickSizeMultiplier = (0.1, 1)
else:
self._tickSizeMultiplier = (1, 0.1)
if style == 'slider':
# Semi-transparent rectangle for a line
self.line._fillColor.alpha = 0.2
self._lineSizeMultiplier = (1, 1)
self._lineSizeAddition = (0, 0)
# Rectangular marker
self.marker.vertices = "rectangle"
self._markerSizeAddition = (0, 0)
if self.horiz:
self._markerSizeMultiplier = (0.1, 0.8)
else:
self._markerSizeMultiplier = (0.8, 0.1)
# Narrow rectangular ticks
self.tickLines.elementMask = None
self._tickSizeAddition = (0, 0)
if self.horiz:
self._tickSizeMultiplier = (0.1, 1)
else:
self._tickSizeMultiplier = (1, 0.1)
if style == 'radio':
# No line
self._lineSizeMultiplier = (0, 0)
self._lineSizeAddition = (0, 0)
# 0.7 scale circular markers
self.marker.vertices = "circle"
self._markerSizeMultiplier = (0.7, 0.7)
self._markerSizeAddition = (0, 0)
# 1:1 circular ticks
self.tickLines.elementMask = 'circle'
self._tickSizeMultiplier = (1, 1)
self._tickSizeAddition = (0, 0)
if style == 'choice':
if self.labels is None:
nLabels = len(self.ticks)
else:
nLabels = len(self.labels)
# No line
if self.horiz:
self._lineSizeMultiplier = (1 + 1 / nLabels, 1)
else:
self._lineSizeMultiplier = (1, 1 + 1 / nLabels)
# Solid ticks
self.tickLines.elementMask = None
self._tickSizeAddition = (0, 0)
self._tickSizeMultiplier = (0, 0)
# Marker is box
self.marker.vertices = "rectangle"
if self.horiz:
self._markerSizeMultiplier = (1, 1)
else:
self._markerSizeMultiplier = (1, 1 / nLabels)
# Labels forced center
self._forceLabelAnchor = "center"
# Choice doesn't make sense with granularity 0
self.granularity = 1
if style == 'scrollbar':
# Semi-transparent rectangle for a line (+ extra area for marker)
self.line.opacity = 1
self.line._fillColor.alpha = 0.2
self._lineSizeAddition = (0, 0)
if self.horiz:
self._lineSizeMultiplier = (1.2, 1)
else:
self._lineSizeMultiplier = (1, 1.2)
# Long rectangular marker
self.marker.vertices = "rectangle"
if self.horiz:
self._markerSizeMultiplier = (0, 1)
self._markerSizeAddition = (self.extent[0] / 5, 0)
else:
self._markerSizeMultiplier = (1, 0)
self._markerSizeAddition = (0, self.extent[1] / 5)
# No ticks
self.tickLines.elementMask = None
self._tickSizeAddition = (0, 0)
self._tickSizeMultiplier = (0, 0)
# Legacy: If given a tweak, apply it as a tweak rather than a style
if style in self.knownStyleTweaks + self.legacyStyleTweaks:
self.styleTweaks.append(style)
# Refresh style tweaks (as these override some aspects of style)
self.styleTweaks = self.styleTweaks
return style
@attributeSetter
def styleTweaks(self, styleTweaks):
"""Sets some predefined style tweaks or use these to create your own.
If you fancy creating and including your own style tweaks that would be great!
Parameters
----------
styleTweaks: list of strings
Known style tweaks currently include:
'triangleMarker': the marker is a triangle
'labels45': the text is rotated by 45 degrees
Legacy style tweaks include:
'whiteOnBlack': a sort of color-inverse rating scale
Legacy style tweaks will work if set in code, but are not exposed in Builder as they are redundant
Style tweaks can be combined in a list e.g. `['labels45']`
"""
if not isinstance(styleTweaks, (list, tuple, np.ndarray)):
styleTweaks = [styleTweaks]
self.__dict__['styleTweaks'] = styleTweaks
if 'triangleMarker' in styleTweaks:
# Vertices for corners of a square
tl = (-0.5, 0.5)
tr = (0.5, 0.5)
bl = (-0.5, -0.5)
br = (0.5, -0.5)
mid = (0, 0)
# Create triangles from 2 corners + center
if self.horiz:
if self.flip:
self.marker.vertices = [mid, bl, br]
else:
self.marker.vertices = [mid, tl, tr]
else:
if self.flip:
self.marker.vertices = [mid, tl, bl]
else:
self.marker.vertices = [mid, tr, br]
if 'labels45' in styleTweaks:
for label in self.labelObjs:
label.ori = -45
# Legacy
if 'whiteOnBlack' in styleTweaks:
self.line.color = 'black'
self.tickLines.colors = 'black'
self.marker.color = 'white'
for label in self.labelObjs:
label.color = 'white'