Repository: psychopy/psychopy

Issue:
Editable TextBox2 issues if formating is used.
Run the demos/coder/stimuli/coder_editable.py example (using the latest master branch source) and press tab to try to make the formatted psychopy test string editable. Error will occur:

```
Traceback (most recent call last):
  File "textbox_editable.py", line 67, in <module>
    win.flip()
  File "psychopy\psychopy\visual\window.py", line 1026, in flip
    thisStim.draw()
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 551, in draw
    self.caret.draw()
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 848, in draw
    gl.glVertex2f(self.vertices[0, 0], self.vertices[0, 1])
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 946, in vertices
    chrVerts = textbox.vertices[range((ii-1) * 4, (ii-1) * 4 + 4)]
IndexError: index 1212 is out of bounds for axis 0 with size 1176
```

The error seems to be from the formating using in the txt2 of the demo. remove the \<b> etc. from the string and tabbing between the textbox2 stims works. 
Editable TextBox2 issues if formating is used.
Run the demos/coder/stimuli/coder_editable.py example (using the latest master branch source) and press tab to try to make the formatted psychopy test string editable. Error will occur:

```
Traceback (most recent call last):
  File "textbox_editable.py", line 67, in <module>
    win.flip()
  File "psychopy\psychopy\visual\window.py", line 1026, in flip
    thisStim.draw()
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 551, in draw
    self.caret.draw()
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 848, in draw
    gl.glVertex2f(self.vertices[0, 0], self.vertices[0, 1])
  File "psychopy\psychopy\visual\textbox2\textbox2.py", line 946, in vertices
    chrVerts = textbox.vertices[range((ii-1) * 4, (ii-1) * 4 + 4)]
IndexError: index 1212 is out of bounds for axis 0 with size 1176
```

The error seems to be from the formating using in the txt2 of the demo. remove the \<b> etc. from the string and tabbing between the textbox2 stims works. 


Discussion:
Fixed original crash. However, need to review how formating is implemented because I'm not sure how well it works in terms of editing the text after intial draw. For example, deleting a char does not take into account formatting.

I also get drawing gliches / artifacts sometimes when deleting text from a textbox with format text. The artifacts seem random. and may not be drawn every frame, as sometimes the screen grab did not show the artifact:

![edit_textbox2_artifact3](https://user-images.githubusercontent.com/1795082/105497479-460be800-5c95-11eb-88f8-20464e4ea1de.png)

The next ones are less obvious, so I drew a red circle around the issue area:

![edit_textbox2_artifact1](https://user-images.githubusercontent.com/1795082/105497536-5cb23f00-5c95-11eb-9f25-9d05feac547e.png)

![edit_textbox2_artifact2](https://user-images.githubusercontent.com/1795082/105497545-60de5c80-5c95-11eb-8ab2-4cf268ac2697.png)

Tested on win10 and macos, both computers show draw glitch sometimes after deleting txt.
Fixed original crash. However, need to review how formating is implemented because I'm not sure how well it works in terms of editing the text after intial draw. For example, deleting a char does not take into account formatting.

I also get drawing gliches / artifacts sometimes when deleting text from a textbox with format text. The artifacts seem random. and may not be drawn every frame, as sometimes the screen grab did not show the artifact:

![edit_textbox2_artifact3](https://user-images.githubusercontent.com/1795082/105497479-460be800-5c95-11eb-88f8-20464e4ea1de.png)

The next ones are less obvious, so I drew a red circle around the issue area:

![edit_textbox2_artifact1](https://user-images.githubusercontent.com/1795082/105497536-5cb23f00-5c95-11eb-9f25-9d05feac547e.png)

![edit_textbox2_artifact2](https://user-images.githubusercontent.com/1795082/105497545-60de5c80-5c95-11eb-8ab2-4cf268ac2697.png)

Tested on win10 and macos, both computers show draw glitch sometimes after deleting txt.

Resolving patch:
diff --git a/psychopy/visual/textbox2/textbox2.py b/psychopy/visual/textbox2/textbox2.py
--- a/psychopy/visual/textbox2/textbox2.py
+++ b/psychopy/visual/textbox2/textbox2.py
@@ -35,6 +35,10 @@
 alphaShader = None
 showWhiteSpace = False
 
+NONE=0
+ITALIC=1
+BOLD=2
+
 codes = {'BOLD_START': u'\uE100',
          'BOLD_END': u'\uE101',
          'ITAL_START': u'\uE102',
@@ -178,6 +182,7 @@ def __init__(self, win, text, font,
         # used at render time
         self._lines = None  # np.array the line numbers for each char
         self._colors = None
+        self._styles = None
         self.flipHoriz = flipHoriz
         self.flipVert = flipVert
         # params about positioning (after layout has occurred)
@@ -203,9 +208,8 @@ def __init__(self, win, text, font,
                 lineWidth=1, lineColor=None, fillColor=fillColor, opacity=0.1,
                 autoLog=False)
         # then layout the text (setting text triggers _layout())
-        self._layoutText = ""
-        self._visibleText = None
         self.startText = text
+        self._text = ''
         self.text = text if text is not None else ""
 
         # caret
@@ -306,23 +310,83 @@ def alignment(self, alignment):
 
         self._needVertexUpdate = True
 
-    @attributeSetter
+    @property
+    def text(self):
+        lastFormatter = NONE
+        formatted_text = ''
+        styles = self._styles
+        for i, c in enumerate(self._text):
+            if styles[i] == ITALIC and lastFormatter != styles[i]:
+                formatted_text+='<i>%s'%(c)
+            elif styles[i] == BOLD and lastFormatter != styles[i]:
+                formatted_text+='<b>%s'%(c)
+            
+            elif styles[i] != ITALIC and lastFormatter == ITALIC:
+                formatted_text+='</i>%s'%(c)
+            elif styles[i] != BOLD and lastFormatter == BOLD:
+                formatted_text+='</b>%s'%(c)
+            else:
+                formatted_text+=c
+            lastFormatter = styles[i]
+        return formatted_text
+    
+    @text.setter
     def text(self, text):
-        self.__dict__['text'] = text
         text = text.replace('<i>', codes['ITAL_START'])
         text = text.replace('</i>', codes['ITAL_END'])
         text = text.replace('<b>', codes['BOLD_START'])
-        text = text.replace('</b>', codes['BOLD_END'])
-        self._layoutText = text        
-        self._visibleText = None
+        text = text.replace('</b>', codes['BOLD_END'])      
+        visible_text = ''.join([c for c in text if c not in codes.values()])
+        self._styles = [0,]*len(visible_text)
+        self._text = visible_text
+        
+        current_style=0
+        ci = 0
+        for c in text:
+            if c == codes['ITAL_START']:
+                current_style += ITALIC
+            elif c == codes['BOLD_START']:
+                current_style += BOLD
+            elif c == codes['BOLD_END']:
+                current_style -= BOLD
+            elif c == codes['ITAL_END']:
+                current_style -= ITALIC
+            else:
+                self._styles[ci]=current_style
+                ci+=1
+                
         self._layout()
 
-    @property
-    def visibleText(self):
-        if self._visibleText is None:
-            self._visibleText = ''.join([c for c in self._layoutText if c not in codes.values()])
-        return self._visibleText
-    
+    def addCharAtCaret(self, char):
+        txt = self._text
+        txt = txt[:self.caret.index] + char + txt[self.caret.index:]
+        cstyle = NONE
+        if len(self._styles) and self.caret.index <= len(self._styles):
+            cstyle = self._styles[self.caret.index-1]
+        self._styles.insert(self.caret.index, cstyle)
+        self.caret.index += 1
+        self._text = txt
+        self._layout()
+
+    def deleteCaretLeft(self):
+        if self.caret.index > 0:
+            txt = self._text
+            ci = self.caret.index
+            txt = txt[:ci-1] + txt[ci:]
+            self._styles = self._styles[:ci-1]+self._styles[ci:]
+            self.caret.index -= 1
+            self._text = txt
+            self._layout()
+
+    def deleteCaretRight(self):
+        ci = self.caret.index
+        if ci < len(self._text):
+            txt = self._text
+            txt = txt[:ci] + txt[ci+1:]
+            self._styles = self._styles[:ci]+self._styles[ci+1:]
+            self._text = txt
+            self._layout()
+        
     def _layout(self):
         """Layout the text, calculating the vertex locations
         """
@@ -335,7 +399,7 @@ def getLineWidthFromPix(pixVal):
         # the vertices are initially pix (natural for freetype)
         # then we convert them to the requested units for self._vertices
         # then they are converted back during rendering using standard BaseStim
-        visible_text = self.visibleText
+        visible_text = self._text
         vertices = np.zeros((len(visible_text) * 4, 2), dtype=np.float32)
         self._charIndices = np.zeros((len(visible_text)), dtype=int)
         self._colors = np.zeros((len(visible_text) * 4, 4), dtype=np.double)
@@ -370,21 +434,17 @@ def getLineWidthFromPix(pixVal):
         wordsThisLine = 0
         lineN = 0
 
-        i =0
-        for charcode in self._layoutText:
+        for i, charcode in enumerate(self._text):
             printable = True  # unless we decide otherwise
             # handle formatting codes
-            if charcode in codes.values():
-                if charcode == codes['ITAL_START']:
-                    fakeItalic = 0.1 * font.size
-                elif charcode == codes['ITAL_END']:
-                    fakeItalic = 0.0
-                elif charcode == codes['BOLD_START']:
-                    fakeBold = 0.3 * font.size
-                elif charcode == codes['BOLD_END']:
-                    current[0] -= fakeBold / 2  # we expected bigger current
-                    fakeBold = 0.0
-                continue
+            if self._styles[i] == NONE:
+                fakeItalic = 0.0
+                fakeBold = 0.0
+            elif self._styles[i] == ITALIC:
+                fakeItalic = 0.1 * font.size
+            elif self._styles[i] == ITALIC:
+                fakeBold = 0.3 * font.size
+
             # handle newline
             if charcode == '\n':
                 printable = False
@@ -473,8 +533,6 @@ def getLineWidthFromPix(pixVal):
                 self._lineBottoms.append(current[1] + font.descender)
                 self._lineTops.append(current[1] + self._lineHeight
                                       + font.descender/2)
-
-            i+=1
             
         # finally add length of this (unfinished) line
         self._lineWidths.append(getLineWidthFromPix(current[0]))
@@ -545,7 +603,7 @@ def draw(self):
         self.shader.bind()
         self.shader.setInt('texture', 0)
         self.shader.setFloat('pixel', [1.0 / 512, 1.0 / 512])
-        nVerts = len(self.text)*4
+        nVerts = len(self._text)*4
 
         gl.glDrawArrays(gl.GL_QUADS, 0, nVerts)
         self.shader.unbind()
@@ -702,9 +760,7 @@ def _onText(self, chr):
             return
         if chr == '\r':  # make it newline not Carriage Return
             chr = '\n'
-        txt = self.text
-        self.text = txt[:self.caret.index] + chr + txt[self.caret.index:]
-        self.caret.index += 1
+        self.addCharAtCaret(chr)
         if self.onTextCallback:
             self.onTextCallback()
 
@@ -719,10 +775,9 @@ def _onCursorKeys(self, key):
         elif key == 'MOTION_LEFT':
             self.caret.char -= 1
         elif key == 'MOTION_BACKSPACE':
-            self.text = self.text[:self.caret.index-1] + self.text[self.caret.index:]
-            self.caret.index -= 1
+            self.deleteCaretLeft()
         elif key == 'MOTION_DELETE':
-            self.text = self.text[:self.caret.index] + self.text[self.caret.index+1:]
+            self.deleteCaretRight()
         elif key == 'MOTION_NEXT_WORD':
             pass
         elif key == 'MOTION_PREVIOUS_WORD':
@@ -754,9 +809,18 @@ def hasFocus(self, state):
         self.draw()
 
     def getText(self):
-        """Returns the current text in the box"""
+        """Returns the current text in the box, including formating tokens."""
         return self.text
 
+    @property
+    def visibleText(self):
+        """Returns the current visible text in the box"""
+        return self._text
+
+    def getVisibleText(self):
+        """Returns the current visible text in the box"""
+        return self.visibleText
+
     @attributeSetter
     def pos(self, value):
         """The position of the center of the TextBox in the stimulus
@@ -831,7 +895,7 @@ class Caret(ColorMixin):
 
     def __init__(self, textbox, color, width, colorSpace='rgb'):
         self.textbox = textbox
-        self.index = len(textbox.visibleText)  # start off at the end
+        self.index = len(textbox._text)  # start off at the end
         self.autoLog = False
         self.width = width
         self.units = textbox.units
@@ -942,8 +1006,8 @@ def char(self, value):
     def vertices(self):
         textbox = self.textbox
         # check we have a caret index
-        if self.index is None or self.index > len(textbox.visibleText):
-            self.index = len(textbox.visibleText)
+        if self.index is None or self.index > len(textbox._text):
+            self.index = len(textbox._text)
         if self.index < 0:
             self.index = 0
         # get the verts of character next to caret (chr is the next one so use


Test patch: