Skip to content

Instantly share code, notes, and snippets.

@joshdoe
Last active August 29, 2015 14:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joshdoe/b530a92e76780e1b6cb5 to your computer and use it in GitHub Desktop.
Save joshdoe/b530a92e76780e1b6cb5 to your computer and use it in GitHub Desktop.
For PsychoPy: BoxLayout and GridLayout to auto-layout stimuli, Button and ButtonGrid to allow single or multiple selections from a list of choices
#!/usr/bin/env python2
'''Creates a buttonff of given width and height
as a special case of a :class:`~psychopy.visual.ShapeStim`'''
# Part of the PsychoPy library
# Copyright (C) 2014 Jonathan Peirce
# Distributed under the terms of the GNU General Public License (GPL).
import numpy as np
import psychopy # so we can get the __path__
from psychopy import logging
from psychopy.tools.attributetools import attributeSetter, logAttrib
from psychopy.visual.basevisual import BaseVisualStim
from psychopy.visual.helpers import setColor
from psychopy.visual.rect import Rect
from psychopy.visual.text import TextStim
from layout import GridLayout
class ButtonStyle:
def __init__(self, fillColor='Gray', borderColor='Black',
textColor='Black'):
self.fillColor = fillColor
self.borderColor = borderColor
self.textColor = textColor
@attributeSetter
def fillColor(self, color):
"""
Sets the color of the button fill. See :meth:`psychopy.visual.GratingStim.color`
for further details of how to use colors.
"""
setColor(self, color, rgbAttrib='fillRGB', colorAttrib='fillColor')
@attributeSetter
def borderColor(self, color):
"""
Sets the color of the button border. See :meth:`psychopy.visual.GratingStim.color`
for further details of how to use colors.
"""
setColor(self, color, rgbAttrib='borderRGB', colorAttrib='borderColor')
@attributeSetter
def textColor(self, color):
"""
Sets the color of the button text. See :meth:`psychopy.visual.GratingStim.color`
for further details of how to use colors.
"""
setColor(self, color, rgbAttrib='textRGB', colorAttrib='textColor')
class Button(BaseVisualStim):
"""Creates a button of given width and height, by combining a
TextStim and a Rect
(New in version 1.80.99 FIXME)
"""
def __init__(self, win,
text='Hello World',
pos=(0.0, 0.0),
width=None,
height=None,
padx=2,
pady=2,
units="",
checked=False,
name=None,
autoLog=None,
autoDraw=False,
):
"""
Button accepts all input parameters, that
`~psychopy.visual.BaseVisualStim` accept, except for vertices
and closeShape.
:Parameters:
width : int or float
Width of the Rectangle (in its respective units, if specified)
height : int or float
Height of the Rectangle (in its respective units, if specified)
"""
# what local vars are defined (these are the init params) for use by
# __repr__
self._initParams = dir()
self._initParams.remove('self')
super(Button, self).__init__(
win, units=units, name=name, autoLog=False)
self.__dict__['pos'] = pos
self.__dict__['text'] = text
self.textStim = TextStim(win, text=text, pos=self.pos, wrapWidth=width,
color='Black', units=units, autoLog=autoLog)
# TODO: expose content_width via TextStim
autoWidth = (self.textStim._pygletTextObj._layout.content_width +
2 * padx)
autoHeight = self.textStim.height + 2 * pady
if width is not None and width > autoWidth:
self.__dict__['width'] = width
else:
self.__dict__['width'] = autoWidth
if height is not None and height > autoHeight:
self.__dict__['height'] = height
else:
self.__dict__['height'] = autoHeight
self.rectStim = Rect(
win, pos=self.pos, width=self.width, height=self.height, units=units, autoLog=autoLog)
self.normalStyle = ButtonStyle()
self.checkedStyle = ButtonStyle(
fillColor='#FF8040', borderColor='Black', textColor='Black')
self.checked = checked # this will set style
self.autoDraw = autoDraw
self.__dict__['autoLog'] = (autoLog or
autoLog is None and self.win.autoLog)
if self.autoLog:
logging.exp("Created %s = %s" % (self.name, str(self)))
def _setStyle(self, style):
self.rectStim.lineColor = style.borderColor
self.rectStim.fillColor = style.fillColor
self.textStim.color = style.textColor
@attributeSetter
def text(self, value):
"""Changes the text of the Button"""
self.__dict__['text'] = value
# TODO: change this to attributeSetters with 1.80.99
self.textStim.setText(value)
@attributeSetter
def pos(self, value):
"""Changes the position of the Button"""
self.__dict__['pos'] = value
self.rectStim.pos = value
self.textStim.pos = value
@attributeSetter
def width(self, value):
"""Changes the width of the Button"""
self.__dict__['width'] = value
# TODO: change this to attributeSetters with 1.80.99
self.rectStim.setWidth(value)
# this won't work, need to construct new or wait for 1.80.99
self.textStim.wrapWidth = value
@attributeSetter
def height(self, value):
"""Changes the height of the Button"""
self.__dict__['height'] = value
# TODO: change this to attributeSetters with 1.80.99
self.rectStim.setHeight(value)
# don't set height of text as it will change font size
# self.textStim.setHeight(value)
@attributeSetter
def checked(self, value):
self.__dict__['checked'] = value
if self.checked:
self._setStyle(self.checkedStyle)
else:
self._setStyle(self.normalStyle)
def setColor(self, color, colorSpace=None, operation=''):
"""For Button use :meth:`~Button.fillColor` or
:meth:`~Button.borderColor` or :meth:`~Button.textColor`
"""
raise AttributeError('Button does not support setColor method.'
'Please use fillColor, borderColor, or textColor')
def contains(self, x, y=None, units=None):
return self.rectStim.contains(x, y, units)
def draw(self, win=None):
"""
Draw the stimulus in its relevant window. You must call
this method after every MyWin.flip() if you want the
stimulus to appear on that frame and then update the screen
again.
If win is specified then override the normal window of this stimulus.
"""
if win is None:
win = self.win
self.rectStim.draw(win)
self.textStim.draw(win)
class ButtonGrid(BaseVisualStim):
def __init__(self, win, labels=[], pos=(0, 0), rows=1, cols=0, padx=3, pady=3,
vgap=10, hgap=10, exclusive=True, autoDraw=True):
super(ButtonGrid, self).__init__(win, autoLog=False)
self.labels = labels
self.__dict__['pos'] = pos
self.__dict__['exclusive'] = exclusive
self.checked = []
self.buttons = []
self.layout = GridLayout(pos, rows, cols, vgap, hgap)
for text in labels:
btn = Button(win=win, text=text, padx=padx, pady=pady)
self.buttons.append(btn)
self.layout.add(btn)
self._update()
self.autoDraw = autoDraw
def _update(self):
self.layout.layout()
self.width = self.layout.width
self.height = self.layout.height
@attributeSetter
def exclusive(self, value):
if self.exclusive is True and value is False:
# TODO: uncheck all but one
raise NotImplementedError
self.__dict__['exclusive'] = value
@attributeSetter
def pos(self, value):
self.__dict__['pos'] = value
self.layout.pos = self.pos
self._update()
def click(self, pos):
clicked = [button for button in self.buttons if button.contains(pos)]
assert len(clicked) <= 1
if not clicked:
return
if self.exclusive:
if clicked[0].checked:
# do nothing, one button has to be checked
# TODO: perhaps some cases where no response can be given?
pass
else:
for btn in self.buttons:
if btn.checked:
btn.checked = False
clicked[0].checked = True
else:
clicked[0].checked = not clicked[0].checked
def checkedLabels(self):
return [btn.text for btn in self.buttons if btn.checked]
def reset(self):
for btn in self.buttons:
btn.checked = False
def draw(self):
for btn in self.buttons:
btn.draw()
if __name__ == "__main__":
from psychopy import core, event, iohub, visual
io = iohub.launchHubServer()
myWin = visual.Window([400, 400], units='pix')
button = Button(myWin, "Test Button", pos=[0, 100])
buttonGrid = ButtonGrid(myWin, [str(x) for x in range(12)], rows=0, cols=4)
trialClock = core.Clock()
while trialClock.getTime() < 20:
for evt in io.devices.mouse.getEvents():
pos = (evt.x_position, evt.y_position)
if evt.type == iohub.constants.EventConstants.MOUSE_BUTTON_PRESS:
buttonGrid.click(pos)
for keys in event.getKeys(timeStamped=True):
if keys[0]in ['escape', 'q']:
myWin.close()
io.quit()
core.quit()
button.draw()
buttonGrid.draw()
myWin.flip()
from __future__ import division
import math
from psychopy.tools.attributetools import attributeSetter
from psychopy import logging
class Spacer:
def __init__(self, size):
self.width = size
self.height = size
self.pos = (0, 0)
class Layout:
pass
class BoxLayout(Layout):
HORIZONTAL = 1
VERTICAL = 2
def __init__(self, orientation=HORIZONTAL, pos=(0, 0), width=None, height=None):
self.items = []
self.prop = []
self.__dict__['orientation'] = orientation
self.__dict__['pos'] = pos
self._width = width
self._height = height
def add(self, item, proportion=0):
self.items.append(item)
self.prop.append(proportion)
def addSpacer(self, size, proportion=0):
self.items.append(Spacer(size))
self.prop.append(proportion)
@attributeSetter
def pos(self, x, y=None):
self.__dict__['pos'] = (x, y) if y is None else x
@attributeSetter
def width(self, w):
self._width = w
@attributeSetter
def height(self, h):
self._height = h
@attributeSetter
def orientation(self, ori):
self.__dict__['orientation'] = ori
def layout(self):
# TODO: this should be more like CalcMin from wxWidgets
for item in self.items:
if isinstance(item, Layout):
item.layout()
majorSizes = []
totalMajor = 0
if self.orientation == BoxLayout.HORIZONTAL:
majorSizes = [i.width for i in self.items]
totalMajor = sum(majorSizes)
if self._width is not None and self._width > totalMajor:
totalMajor = self._width
logging.warning('Minimum width exceeds desired width: %d > %d' % (
totalMajor, self._width))
self.width = totalMajor
maxMinor = max([i.height for i in self.items])
if self.height is None:
self.height = maxMinor
elif maxMinor > self.height:
logging.warning('Height of child items exceed height of layout: %d > %d' % (
maxMinor, self.height))
self.height = maxMinor
elif self.orientation == BoxLayout.VERTICAL:
majorSizes = [i.height for i in self.items]
totalMajor = sum(majorSizes)
if self._height is not None and self._height > totalMajor:
totalMajor = self._height
logging.warning('Minimum height exceeds desired height: %d > %d' % (
totalMajor, self._height))
self.height = totalMajor
maxMinor = max([i.width for i in self.items])
if self.width is None:
self.width = maxMinor
elif maxMinor > self.width:
logging.warning(
'Width of child items exceed width of layout: %d > %d' % (maxMinor, self.width))
self.width = maxMinor
else:
raise NotImplementedError(
'Only HORIZONTAL and VERTICAL orientations supported')
# give minimum space to all
sizes = majorSizes
# distribute remaining space
remaining = totalMajor - sum(sizes)
totalProp = sum(self.prop)
if totalProp > 0:
for i in range(len(sizes)):
sizes[i] += (self.prop[i] / totalProp) * remaining
if self.orientation == BoxLayout.HORIZONTAL:
left = self.pos[0] - sum(sizes) / 2
for i, item in enumerate(self.items):
left += sizes[i] / 2
item.pos = (left, self.pos[1])
left += sizes[i] / 2
elif self.orientation == BoxLayout.VERTICAL:
top = self.pos[1] + sum(sizes) / 2
for i, item in enumerate(self.items):
top -= sizes[i] / 2
item.pos = (self.pos[0], top)
top -= sizes[i] / 2
else:
raise NotImplementedError(
'Only HORIZONTAL and VERTICAL orientations supported')
# layout children
for item in self.items:
if isinstance(item, Layout):
item.layout()
class GridLayout(Layout):
def __init__(self, pos=(0, 0), rows=1, cols=0, vgap=0, hgap=0):
self.__dict__['pos'] = pos
self.__dict__['rows'] = rows
self.__dict__['cols'] = cols
self.__dict__['vgap'] = vgap
self.__dict__['hgap'] = hgap
self.items = []
def add(self, item):
self.items.append(item)
def layout(self):
# TODO: this should be more like CalcMin from wxWidgets
for item in self.items:
if isinstance(item, Layout):
item.layout()
rows = self.rows
cols = self.cols
assert rows or cols
if rows == 0:
rows = int(math.ceil(len(self.items) / cols))
elif cols == 0:
cols = int(math.ceil(len(self.items) / rows))
assert rows * cols >= len(self.items)
maxHeights = []
for r in range(rows):
items = self.items[r * cols:r * cols + cols]
maxHeight = max([i.height for i in items])
maxHeights.append(maxHeight)
# TODO: only change height if desired
for i in items:
i.height = maxHeight
maxWidths = []
for c in range(cols):
items = self.items[c::cols]
maxWidth = max([i.width for i in items])
maxWidths.append(maxWidth)
# TODO: only change width if desired
for i in items:
i.width = maxWidth
totalWidth = sum(maxWidths) + self.hgap * (cols - 1)
totalHeight = sum(maxHeights) + self.vgap * (cols - 1)
y = self.pos[1] + totalHeight / 2
for row in range(rows):
y -= maxHeights[row] / 2
# FIXME: if x is 0, button isn't shown, so add eps for now
x = self.pos[0] - totalWidth / 2 + 0.1
for col in range(cols):
x += maxWidths[col] / 2
item = self.items[row * cols + col]
item.setPos([x, y])
x += maxWidths[col] / 2 + self.hgap
y -= maxHeights[row] / 2 + self.vgap
self.width = totalWidth
self.height = totalHeight
# layout children
for item in self.items:
if isinstance(item, Layout):
item.layout()
if __name__ == "__main__":
from psychopy import core, event, iohub, visual
from button import Button, ButtonGrid
win = visual.Window([800, 600], units='pix')
vbox = BoxLayout(orientation=BoxLayout.VERTICAL)
imlayout = BoxLayout(orientation=BoxLayout.HORIZONTAL)
btn = Button(win, 'Image 1 here', width=320, height=240, autoDraw=True)
imlayout.add(btn)
imlayout.addSpacer(50)
btn = Button(win, 'Image 2 here', width=320, height=240, autoDraw=True)
imlayout.add(btn)
vbox.add(imlayout)
vbox.addSpacer(50)
buttonGrid = ButtonGrid(win, [str(x) for x in range(12)], rows=0, cols=4)
vbox.add(buttonGrid)
# this will layout children
vbox.layout()
trialClock = core.Clock()
while trialClock.getTime() < 20:
for keys in event.getKeys(timeStamped=True):
if keys[0]in ['escape', 'q']:
win.close()
core.quit()
win.flip()
@jeremygray
Copy link

what version of layout do you use? I can't import GridLayout from layout 0.2.4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment