#!/usr/bin/env python# -*- coding: utf-8 -*-'''Class of text stimuli to be displayed in a :class:`~psychopy.visual.Window`'''# Part of the PsychoPy library# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.# Distributed under the terms of the GNU General Public License (GPL).importosimportglobimportwarnings# Ensure setting pyglet.options['debug_gl'] to False is done prior to any# other calls to pyglet or pyglet submodules, otherwise it may not get picked# up by the pyglet GL engine and have no effect.# Shaders will work but require OpenGL2.0 drivers AND PyOpenGL3.0+importpygletpyglet.options['debug_gl']=FalseimportctypesGL=pyglet.glimportpsychopy# so we can get the __path__frompsychopyimportlogging# tools must only be imported *after* event or MovieStim breaks on win32# (JWP has no idea why!)frompsychopy.tools.monitorunittoolsimportcm2pix,deg2pix,convertToPixfrompsychopy.tools.attributetoolsimportattributeSetter,setAttributefrompsychopy.visual.basevisualimport(BaseVisualStim,DraggingMixin,ForeColorMixin,ContainerMixin,WindowMixin)frompsychopy.colorsimportColor# for displaying right-to-left (possibly bidirectional) text correctly:frombidiimportalgorithmasbidi_algorithm# sufficient for Hebrew# extra step needed to reshape Arabic/Farsi characters depending on# their neighbours:try:fromarabic_reshaperimportArabicReshaperhaveArabic=TrueexceptImportError:haveArabic=Falseimportnumpytry:importpygamehavePygame=TrueexceptException:havePygame=FalsedefaultLetterHeight={'cm':1.0,'deg':1.0,'degs':1.0,'degFlatPos':1.0,'degFlat':1.0,'norm':0.1,'height':0.2,'pix':20,'pixels':20}defaultWrapWidth={'cm':15.0,'deg':15.0,'degs':15.0,'degFlatPos':15.0,'degFlat':15.0,'norm':1,'height':1,'pix':500,'pixels':500}
[docs]classTextStim(BaseVisualStim,DraggingMixin,ForeColorMixin,ContainerMixin):"""Class of text stimuli to be displayed in a :class:`~psychopy.visual.Window` """def__init__(self,win,text="Hello World",font="",pos=(0.0,0.0),depth=0,rgb=None,color=(1.0,1.0,1.0),colorSpace='rgb',opacity=1.0,contrast=1.0,units="",ori=0.0,height=None,antialias=True,bold=False,italic=False,alignHoriz=None,alignVert=None,alignText='center',anchorHoriz='center',anchorVert='center',fontFiles=(),wrapWidth=None,flipHoriz=False,flipVert=False,languageStyle='LTR',draggable=False,name=None,autoLog=None,autoDraw=False):""" **Performance OBS:** in general, TextStim is slower than many other visual stimuli, i.e. it takes longer to change some attributes. In general, it's the attributes that affect the shapes of the letters: ``text``, ``height``, ``font``, ``bold`` etc. These make the next .draw() slower because that sets the text again. You can make the draw() quick by calling re-setting the text (``myTextStim.text = myTextStim.text``) when you've changed the parameters. In general, other attributes which merely affect the presentation of unchanged shapes are as fast as usual. This includes ``pos``, ``opacity`` etc. The following attribute can only be set at initialization (see further down for a list of attributes which can be changed after initialization): **languageStyle** Apply settings to correctly display content from some languages that are written right-to-left. Currently there are three (case- insensitive) values for this parameter: - ``'LTR'`` is the default, for typical left-to-right, Latin-style languages. - ``'RTL'`` will correctly display text in right-to-left languages such as Hebrew. By applying the bidirectional algorithm, it allows mixing portions of left-to-right content (such as numbers or Latin script) within the string. - ``'Arabic'`` applies the bidirectional algorithm but additionally will _reshape_ Arabic characters so they appear in the cursive, linked form that depends on neighbouring characters, rather than in their isolated form. May also be applied in other scripts, such as Farsi or Urdu, that use Arabic-style alphabets. :Parameters: """# what local vars are defined (these are the init params) for use by# __repr__self._initParams=dir()self._initParams.remove('self')""" October 2018: In place to remove the deprecation warning for pyglet.font.Text. Temporary fix until pyglet.text.Label use is identical to pyglet.font.Text. """warnings.filterwarnings(message='.*text.Label*',action='ignore')super(TextStim,self).__init__(win,units=units,name=name,autoLog=False)self.draggable=draggableifwin.blendMode=='add':logging.warning("Pyglet text does not honor the Window setting ""`blendMode='add'` so 'avg' will be used for the ""text (but objects drawn after can be added)")self._needUpdate=Trueself._needVertexUpdate=True# use shaders if available by default, this is a good thingself.__dict__['antialias']=antialiasself.__dict__['font']=fontself.__dict__['bold']=boldself.__dict__['italic']=italic# NB just a placeholder - real value set belowself.__dict__['text']=''self.__dict__['depth']=depthself.__dict__['ori']=oriself.__dict__['flipHoriz']=flipHorizself.__dict__['flipVert']=flipVertself.__dict__['languageStyle']=languageStyleiflanguageStyle.lower()=='arabic':arabic_config={'delete_harakat':False,# if present, retain any diacritics'shift_harakat_position':True}# shift by 1 to be compatible with the bidi algorithmself.__dict__['arabic_reshaper']=ArabicReshaper(configuration=arabic_config)self._pygletTextObj=Noneself.pos=pos# deprecated attributesifalignVert:self.__dict__['alignVert']=alignVertlogging.warning("TextStim.alignVert is deprecated. Use the ""anchorVert attribute instead")# for compatibility, alignText was historically 'left'anchorVert=alignHorizifalignHoriz:self.__dict__['alignHoriz']=alignHorizlogging.warning("TextStim.alignHoriz is deprecated. Use alignText ""and anchorHoriz attributes instead")# for compatibility, alignText was historically 'left'alignText,anchorHoriz=alignHoriz,alignHoriz# alignment and anchorsself.alignText=alignTextself.anchorHoriz=anchorHorizself.anchorVert=anchorVert# generate the texture and list holdersself._listID=GL.glGenLists(1)# pygame text needs a surface to render to:ifnotself.win.winTypein["pyglet","glfw"]:self._texID=GL.GLuint()GL.glGenTextures(1,ctypes.byref(self._texID))# Color stuffself.colorSpace=colorSpaceself.color=colorifrgb!=None:logging.warning("Use of rgb arguments to stimuli are deprecated. Please ""use color and colorSpace args instead")self.color=Color(rgb,'rgb')self.__dict__['fontFiles']=[]self.fontFiles=list(fontFiles)# calls attributeSetterself.setHeight(height,log=False)# calls setFont() at some point# calls attributeSetter without logsetAttribute(self,'wrapWidth',wrapWidth,log=False)self.opacity=opacityself.contrast=contrast# self.width and self._fontHeightPix get set with text and# calcSizeRendered is calledself.setText(text,log=False)self._needUpdate=Trueself.autoDraw=autoDraw# set autoLog now that params have been initialisedwantLog=autoLogisNoneandself.win.autoLogself.__dict__['autoLog']=autoLogorwantLogifself.autoLog:logging.exp("Created %s = %s"%(self.name,str(self)))def__del__(self):ifGL:# because of pytest fail otherwisetry:GL.glDeleteLists(self._listID,1)except(ImportError,ModuleNotFoundError,TypeError,GL.lib.GLException):pass# if pyglet no longer exists@attributeSetterdefheight(self,height):"""The height of the letters (Float/int or None = set default). Height includes the entire box that surrounds the letters in the font. The width of the letters is then defined by the font. :ref:`Operations <attrib-operations>` supported."""# height in pix (needs to be done after units which is done during# _Base.__init__)ifheightisNone:ifself.unitsindefaultLetterHeight:height=defaultLetterHeight[self.units]else:msg=("TextStim does now know a default letter height ""for units %s")raiseAttributeError(msg%repr(self.units))self.__dict__['height']=heightself._heightPix=convertToPix(pos=numpy.array([0,0]),vertices=numpy.array([0,self.height]),units=self.units,win=self.win)[1]# need to update the font to reflect the changeself.setFont(self.font,log=False)returnself.__dict__['height']@propertydefsize(self):self.size=(self.height*len(self.text),self.height)returnWindowMixin.size.fget(self)@size.setterdefsize(self,value):WindowMixin.size.fset(self,value)self.height=getattr(self._size,self.units)[1]
[docs]defsetHeight(self,height,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message. """setAttribute(self,'height',height,log)
[docs]defsetLetterHeight(self,height,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message. """setAttribute(self,'height',height,log)
@attributeSetterdeffont(self,font):"""String. Set the font to be used for text rendering. font should be a string specifying the name of the font (in system resources). """self.__dict__['font']=None# until we find oneifself.win.winTypein["pyglet","glfw"]:self._font=pyglet.font.load(font,int(self._heightPix),dpi=72,italic=self.italic,bold=self.bold)self.__dict__['font']=fontelse:iffontisNoneorlen(font)==0:self.__dict__['font']=pygame.font.get_default_font()eliffontinpygame.font.get_fonts():self.__dict__['font']=fonteliftype(font)==str:# try to find a xxx.ttf file for it# check for possible matching filenamesfontFilenames=glob.glob(font+'*')iflen(fontFilenames)>0:forthisFontinfontFilenames:ifthisFont[-4:]in['.TTF','.ttf']:# take the first matchself.__dict__['font']=thisFontbreak# stop at the first one we find# trhen check if we were successfulifself.fontisNoneandfont!="":# we didn't find a ttf filenamemsg=("Found %s but it doesn't end .ttf. ""Using default font.")logging.warning(msg%fontFilenames[0])self.__dict__['font']=pygame.font.get_default_font()ifself.fontisnotNoneandos.path.isfile(self.font):self._font=pygame.font.Font(self.font,int(self._heightPix),italic=self.italic,bold=self.bold)else:try:self._font=pygame.font.SysFont(self.font,int(self._heightPix),italic=self.italic,bold=self.bold)self.__dict__['font']=fontlogging.info('using sysFont '+str(font))exceptException:self.__dict__['font']=pygame.font.get_default_font()msg=("Couldn't find font %s on the system. Using %s ""instead! Font names should be written as ""concatenated names all in lower case.\ne.g. ""'arial', 'monotypecorsiva', 'rockwellextra', ...")logging.error(msg%(font,self.font))self._font=pygame.font.SysFont(self.font,int(self._heightPix),italic=self.italic,bold=self.bold)# re-render text after a font changeself._needSetText=True
[docs]defsetFont(self,font,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message. """setAttribute(self,'font',font,log)
@attributeSetterdeftext(self,text):"""The text to be rendered. Use \\\\n to make new lines. Issues: May be slow, and pyglet has a memory leak when setting text. For these reasons, this function checks so that it only updates the text if it has changed. So scripts can safely set the text on every frame, with no need to check if it has actually altered. """iftext==self.text:# only update for a changereturniftextisnotNone:text=str(text)# make sure we have unicode object to render# deal with some international text issues. Only relevant for Python:# online experiments use web technologies and handle this seamlessly.style=self.languageStyle.lower()# be flexible with caseifstyle=='arabic'andhaveArabic:# reshape Arabic characters from their isolated form so that# they flow and join correctly to their neighbours:text=self.arabic_reshaper.reshape(text)ifstyle=='rtl'or(style=='arabic'andhaveArabic):# deal with right-to-left text presentation by applying the# bidirectional algorithm:text=bidi_algorithm.get_display(text)# no action needed for default 'ltr' (left-to-right) optionself.__dict__['text']=textself._setTextShaders(text)self._needSetText=Falsereturnself.__dict__['text']
[docs]defsetText(self,text=None,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message. """setAttribute(self,'text',text,log)
[docs]def_setTextShaders(self,value=None):"""Set the text to be rendered using the current font """ifself.win.winTypein["pyglet","glfw"]:rgba255=self._foreColor.rgba255rgba255[3]=rgba255[3]*255rgba255=[int(c)forcinrgba255]self._pygletTextObj=pyglet.text.Label(self.text,self.font,int(self._heightPix*0.75),italic=self.italic,bold=self.bold,anchor_x=self.anchorHoriz,anchor_y=self.anchorVert,# the point we rotate aroundalign=self.alignText,color=rgba255,multiline=True,width=self._wrapWidthPix)# width of the frameself.width=self._pygletTextObj.widthself._fontHeightPix=self._pygletTextObj.heightelse:self._surf=self._font.render(value,self.antialias,[255,255,255])self.width,self._fontHeightPix=self._surf.get_size()ifself.antialias:smoothing=GL.GL_LINEARelse:smoothing=GL.GL_NEAREST# generate the textures from pygame surfaceGL.glEnable(GL.GL_TEXTURE_2D)# bind that name to the targetGL.glBindTexture(GL.GL_TEXTURE_2D,self._texID)GL.gluBuild2DMipmaps(GL.GL_TEXTURE_2D,4,self.width,self._fontHeightPix,GL.GL_RGBA,GL.GL_UNSIGNED_BYTE,pygame.image.tostring(self._surf,"RGBA",1))# linear smoothing if texture is stretched?GL.glTexParameteri(GL.GL_TEXTURE_2D,GL.GL_TEXTURE_MAG_FILTER,smoothing)# but nearest pixel value if it's compressed?GL.glTexParameteri(GL.GL_TEXTURE_2D,GL.GL_TEXTURE_MIN_FILTER,smoothing)self._needSetText=Falseself._needUpdate=True
[docs]def_updateListShaders(self):"""Only used with pygame text - pyglet handles all from the draw() """ifself._needSetText:self.setText(log=False)GL.glNewList(self._listID,GL.GL_COMPILE)# GL.glPushMatrix()# setup the shaderprogram# no need to do texture maths so no need for programs?# If we're using pyglet then this list won't be called, and for pygame# shaders aren't enabledGL.glUseProgram(0)# self.win._progSignedTex)# GL.glUniform1i(GL.glGetUniformLocation(self.win._progSignedTex,# "texture"), 0) # set the texture to be texture unit 0# coords:ifself.alignHorizin['center','centre']:left=-self.width/2.0right=self.width/2.0elifself.alignHoriz=='right':left=-self.widthright=0.0else:left=0.0right=self.width# how much to move bottomifself.alignVertin['center','centre']:bottom=-self._fontHeightPix/2.0top=self._fontHeightPix/2.0elifself.alignVert=='top':bottom=-self._fontHeightPixtop=0else:bottom=0.0top=self._fontHeightPix# there seems to be a rounding err in pygame font texturesBtex,Ttex,Ltex,Rtex=-0.01,0.98,0,1.0# unbind the mask texture regardlessGL.glActiveTexture(GL.GL_TEXTURE1)GL.glEnable(GL.GL_TEXTURE_2D)GL.glBindTexture(GL.GL_TEXTURE_2D,0)ifself.win.winTypein["pyglet","glfw"]:# unbind the main textureGL.glActiveTexture(GL.GL_TEXTURE0)# GL.glActiveTextureARB(GL.GL_TEXTURE0_ARB)# the texture is specified by pyglet.font.GlyphString.draw()GL.glBindTexture(GL.GL_TEXTURE_2D,0)GL.glEnable(GL.GL_TEXTURE_2D)else:# bind the appropriate main textureGL.glActiveTexture(GL.GL_TEXTURE0)GL.glBindTexture(GL.GL_TEXTURE_2D,self._texID)GL.glEnable(GL.GL_TEXTURE_2D)ifself.win.winTypein["pyglet","glfw"]:GL.glActiveTexture(GL.GL_TEXTURE0)GL.glEnable(GL.GL_TEXTURE_2D)self._pygletTextObj.draw()else:# draw a 4 sided polygonGL.glBegin(GL.GL_QUADS)# right bottomGL.glMultiTexCoord2f(GL.GL_TEXTURE0,Rtex,Btex)GL.glVertex3f(right,bottom,0)# left bottomGL.glMultiTexCoord2f(GL.GL_TEXTURE0,Ltex,Btex)GL.glVertex3f(left,bottom,0)# left topGL.glMultiTexCoord2f(GL.GL_TEXTURE0,Ltex,Ttex)GL.glVertex3f(left,top,0)# right topGL.glMultiTexCoord2f(GL.GL_TEXTURE0,Rtex,Ttex)GL.glVertex3f(right,top,0)GL.glEnd()GL.glDisable(GL.GL_TEXTURE_2D)GL.glUseProgram(0)# GL.glPopMatrix()GL.glEndList()self._needUpdate=False
@attributeSetterdefflipHoriz(self,value):"""If set to True then the text will be flipped left-to-right. The flip is relative to the original, not relative to the current state. """self.__dict__['flipHoriz']=value
[docs]defsetFlipHoriz(self,newVal=True,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message. """setAttribute(self,'flipHoriz',newVal,log)
@attributeSetterdefflipVert(self,value):"""If set to True then the text will be flipped top-to-bottom. The flip is relative to the original, not relative to the current state. """self.__dict__['flipVert']=value
[docs]defsetFlipVert(self,newVal=True,log=None):"""Usually you can use 'stim.attribute = value' syntax instead, but use this method if you need to suppress the log message """setAttribute(self,'flipVert',newVal,log)
[docs]defsetFlip(self,direction,log=None):"""(used by Builder to simplify the dialog) """ifdirection=='vert':self.setFlipVert(True,log=log)elifdirection=='horiz':self.setFlipHoriz(True,log=log)
@attributeSetterdefantialias(self,value):"""Allow antialiasing the text (True or False). Sets text, slow. """self.__dict__['antialias']=valueself._needSetText=True@attributeSetterdefbold(self,value):"""Make the text bold (True, False) (better to use a bold font name). """self.__dict__['bold']=valueself.font=self.font# call attributeSetter@attributeSetterdefitalic(self,value):"""True/False. Make the text italic (better to use a italic font name). """self.__dict__['italic']=valueself.font=self.font# call attributeSetter@attributeSetterdefalignHoriz(self,value):"""Deprecated in PsychoPy 3.3. Use `alignText` and `anchorHoriz` instead """self.__dict__['alignHoriz']=valueself._needSetText=True@attributeSetterdefalignVert(self,value):"""Deprecated in PsychoPy 3.3. Use `anchorVert` """self.__dict__['alignVert']=valueself._needSetText=True@attributeSetterdefalignText(self,value):"""Aligns the text content within the bounding box ('left', 'right' or 'center') See also `anchorX` to set alignment of the box itself relative to pos """self.__dict__['alignText']=valueself._needSetText=True@attributeSetterdefanchorHoriz(self,value):"""The horizontal alignment ('left', 'right' or 'center') """self.__dict__['anchorHoriz']=valueself._needSetText=True@attributeSetterdefanchorVert(self,value):"""The vertical alignment ('top', 'bottom' or 'center') of the box relative to the text `pos`. """self.__dict__['anchorVert']=valueself._needSetText=True@attributeSetterdeffontFiles(self,fontFiles):"""A list of additional files if the font is not in the standard system location (include the full path). OBS: fonts are added every time this value is set. Previous are not deleted. E.g.:: stim.fontFiles = ['SpringRage.ttf'] # load file(s) stim.font = 'SpringRage' # set to font """self.__dict__['fontFiles']+=fontFilesforthisFontinfontFiles:pyglet.font.add_file(thisFont)@attributeSetterdefwrapWidth(self,wrapWidth):"""Int/float or None (set default). The width the text should run before wrapping. :ref:`Operations <attrib-operations>` supported. """ifwrapWidthisNone:ifself.unitsindefaultWrapWidth:wrapWidth=defaultWrapWidth[self.units]else:msg="TextStim does now know a default wrap width for units %s"raiseAttributeError(msg%repr(self.units))self.__dict__['wrapWidth']=wrapWidthverts=numpy.array([self.wrapWidth,0])self._wrapWidthPix=convertToPix(pos=numpy.array([0,0]),vertices=verts,units=self.units,win=self.win)[0]self._needSetText=True@propertydefboundingBox(self):"""(read only) attribute representing the bounding box of the text (w,h). This differs from `width` in that the width represents the width of the margins, which might differ from the width of the text within them. NOTE: currently always returns the size in pixels (this will change to return in stimulus units) """ifhasattr(self._pygletTextObj,'content_width'):w,h=(self._pygletTextObj.content_width,self._pygletTextObj.content_height)else:w,h=(self._pygletTextObj._layout.content_width,self._pygletTextObj._layout.content_height)returnw,h@propertydefposPix(self):"""This determines the coordinates in pixels of the position for the current stimulus, accounting for pos and units. This property should automatically update if `pos` is changed"""# because this is a property getter we can check /on-access/ if it# needs updating :-)ifself._needVertexUpdate:self.__dict__['posPix']=self._pos.pixself._needVertexUpdate=Falsereturnself.__dict__['posPix']
[docs]defdraw(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. """ifwinisNone:win=self.winself._selectWindow(win)blendMode=win.blendMode# keep track for reset laterGL.glPushMatrix()# for PyOpenGL this is necessary despite pop/PushMatrix, (not for# pyglet)GL.glLoadIdentity()#scale and rotateprevScale=win.setScale('pix')# to units for translations# NB depth is set alreadyGL.glTranslatef(self.posPix[0],self.posPix[1],0)GL.glRotatef(-self.ori,0.0,0.0,1.0)# back to pixels for drawing surfacewin.setScale('pix',None,prevScale)GL.glScalef((1,-1)[self.flipHoriz],(1,-1)[self.flipVert],1)# x,y,z; -1=flipped# setup colorGL.glColor4f(*self._foreColor.render('rgba1'))GL.glUseProgram(self.win._progSignedTexFont)# GL.glUniform3iv(GL.glGetUniformLocation(# self.win._progSignedTexFont, "rgb"), 1,# desiredRGB.ctypes.data_as(ctypes.POINTER(ctypes.c_float)))# # set the texture to be texture unit 0GL.glUniform3f(GL.glGetUniformLocation(self.win._progSignedTexFont,b"rgb"),*self._foreColor.render('rgb1'))# should text have a depth or just on top?GL.glDisable(GL.GL_DEPTH_TEST)# update list if necss and then call itifwin.winTypein["pyglet","glfw"]:ifself._needSetText:self.setText()# unbind the mask texture regardlessGL.glActiveTexture(GL.GL_TEXTURE1)GL.glEnable(GL.GL_TEXTURE_2D)GL.glBindTexture(GL.GL_TEXTURE_2D,0)# unbind the main textureGL.glActiveTexture(GL.GL_TEXTURE0)GL.glEnable(GL.GL_TEXTURE_2D)# then allow pyglet to bind and use texture during drawingself._pygletTextObj.draw()GL.glDisable(GL.GL_TEXTURE_2D)else:# for pygame we should (and can) use a drawing listifself._needUpdate:self._updateList()GL.glCallList(self._listID)# pyglets text.draw() method alters the blend func so reassert ourswin.setBlendMode(blendMode,log=False)GL.glUseProgram(0)# GL.glEnable(GL.GL_DEPTH_TEST) # Enables Depth TestingGL.glPopMatrix()