Make App Pie

Training for Developers and Artists

From Apple to Raspberry Pi: A Scroll List Box that Works.

For the last few posts, we have tried to get past the Tkinter limitation with scrolling list boxes using only one word per line. This post we take the Text widget we added last week, and turn it into a true list box. We will be working completely within the view, though creating one new property for use by the view controller – an index.

Code from the last post is on github here, if you wish to use it to follow along.

Our New Property

Before we get started, add the following line  to __init__() in the MyView class:

        self.selectedLine = "1"

We now have initialized our new property, which gives us an index of the selected line in the text box. Text boxes are a bit odd, in that they start with row 1, not row 0. If we want to use this information in our view controller eventually, we will need a getter that subtracts 1 to match the standard indexing system. Add this to our getters and setters in MyView

    def getSelectedLine(self): #for use externally as an index. Do not use the property in the view controller
        return self.selectedLine - 1

Events

Up to this point, we have used the command attribute to control events. For example:

add_button = ttk.Button(buttonFrame,command= self.vc.addPressed,text = 'Add')

Text buttons do not have a command attribute. Instead we need to bind and handle the event ourselves. We’ll use the Tkinter method .bind(). Change the code for the text scroll box to this:

#set up the text box
        self.dataList_textbox = scrolledtext.ScrolledText(listFrame, tabs= ("10c"))
        self.dataList_textbox.grid(row=1, column = 0, sticky = NSEW);
 #       self.dataList_textbox.insert("0.0","Penguin\tPenguin Action") #test data commented out v6
        #Added event binding to a button release
        self.dataList_textbox.bind('',self.mouseSelectTextLine)

We commented out our line 4 test data and added line 6. The .bind() method binds the event loop to the text box, so events that involve the text box will run a handler. There are two required parameters here: the event and the handler if the event happens. Events include both mouse and keyboard events. The format for an event is a string with < and > surrounding it. In our case we are looking for the ButtonRelease event. We could have used a Button event for a mouse button down. But for several reasons it is better practice to start an event on releasing than pressing the mouse button. When we have a ButtonRelease event in the text box, Python  executes the code self.mouseSelectTextLine. For the MVC pattern, this handler is still in the view not the view controller. These  are changes to internal workings of the text box, so it does not belong in the view controller. We address the view controller next week.

Adding the Handler

We set up a listener for an event, next we need the handeler for that event. Add the following code after the text box’s setters and getters:

# Internal behavior for the text box
    def mouseSelectTextLine(self, event):
        mousePosition = "@"+str(event.x)+","+ str(event.y) #get the mouse position
        self.selectedIndex = self.dataList_textbox.index(mousePosition) #convert to textbox index
        splitIndex = self.selectedIndex.split('.') #get the row we are on
        selectedLine = splitIndex[0]
        self.highlightTextLine(selectedLine)

Unlike previous handlers, event handlers have an event parameter, which is a special class of event information. It has properties about the event, such as  key presses, the mouse’s current position, and the action of the mouse buttons. In line 3, we use event.x and event.y to get the mouse position. We discussed in the last post, the index format of Text boxes. The rest of line 3 transforms the event data into a text box index of a mouse position. For our list box, we will need what character line we are on, so in line 4 we use the .index() method of a Text widget which takes the mouse coordinates and transforms them into character coordinates in the string "line.column".

We do not care about columns, we only need the line. Lines 5 and 6 use Python’s split() method for strings to convert the index to a list. The split() method uses a string '.' for a delimiter. Once a string, we get the first element of the string, which is the row represented as a string. In line 7, we send that row to the method we’ll use to highlight the row.

Adding Tags

The Text box is a bit different from our other widgets when it come to formatting. Text widgets are small word processors, with a lot more formatting capabilities than a Label widget. To do this, they use a system of tags to set formatting. A tag consists of a name and a range of the tag. Once set, format the tag with the .tag_config() method.

The goal is to convert a Text widget to a list box. For a List Box, usually the line selected has a reversed foreground and background. We will create a tag from the beginning to the end of a line to reverse the text there. Text indices are strings. We do not have to convert it to an integer, just concatenate a few constants to it.

Add the following just below the code we just added:

    def highlightTextLine(self,selectedLine):
        self.selectedLine=int(selectedLine)
        selectedLineStart = selectedLine + ".0" #beginning and end of line
        selectedLineEnd = self.dataList_textbox.index(selectedLine + ".end")
        #highlight the line on the box
        self.dataList_textbox.tag_delete("selectedLine")
        self.dataList_textbox.tag_add("selectedLine",selectedLineStart,selectedLineEnd)
        self.dataList_textbox.tag_config("selectedLine",background = "blue",foreground = "white")

Although we do not need a string for our internal processing, the purpose of building a List box is to get an integer of a selected list so we access data by an index. Line 2 above save our integer in a property, and allows us to use our mouse selected row in a few other places.

Lines 3 and 4 creates strings representing our beginning and end positions. Line 3 just adds “.0” to the string, signifying the beginning of the line. Line 4 again uses the .index() method for Text to find the index at the end of the line. We could write line 4 without the .index() method, but it helps with debugging to do it this way.

Lines 6 through 8 add the tag. We are setting up a single selection list box, so every time we select a new row, we set the previous row back to its normal condition by deleting the tag in line 6. In line 7, we add the tag with the .tag_add() naming the tag selectline and specifying our beginning and end of the tag. In line 8, we change the foreground and background attributes to a reversed state.

With that we can save and run. Add a few penguins and select a line, and then another line:

Screenshot 2014-06-24 08.32.19
A working text box in Tkinter

Adding Tkinter Keyboard Events

Most list boxes allow for keyboard controls. Let’s add the up and down keys to move the selection up or down one line. First add the following two bindings below the mouse binding:

        self.dataList_textbox.bind('<Down>',self.kbSelectTextLine)
        self.dataList_textbox.bind('<Up>',self.kbSelectTextLine)

The strings Up and Down are the up and down key events. We’ll use the same handler for both. Between the two methods we just wrote add this:

    def kbSelectTextLine(self,event):
        selected=self.selectedLine
        if event.keysym == "Up":
            selectedLine = str(selected - 1)
        else:
            selectedLine = str(selected + 1)
        self.highlightTextLine(selectedLine)

In line 3, we use another property of the event object .keysym which returns the symbol for the key pressed. If the user presses the up key, we subtract one and if down, we add one. Save and run. Add some penguins and click with the mouse a line, then use the up and down arrow keys.

A working text box
A working text box

Those headings are still a bit of a problem. We can select the headings. They mess up the index we are trying to get out of the text box by adding a few lines. Along with what we can do with the text box in the view controller, that will be the lesson next time.

The Whole Code

Below is the code for our application so far. You can also find it here at Github

# poppers_penguins_MVC_06a
#MVC Version 2014 May 28
#Change: Adds the text box

from tkinter import *
from tkinter import ttk
from tkinter import messagebox
from tkinter import scrolledtext
 
 
class MyViewController():

    
    def __init__(self,parent):

        self.reportHeader = "Penguin Type\tPenguin Action\n"+ "="*35 +"\t"+"="*35+"\n"
        self.parent = parent;       
        self.view = MyView(self)
        self.model = MyModel(self)
        
    #Handlers -- target action
    def addPressed(self):
        self.view.setLabelText(self.view.getPenguinType()+ ' Penguin '+ self.view.getPenguinAction() + ' Added')
        self.model.addRecordToList(self.view.getPenguinType(),self.view.getPenguinAction())
    def quitPressed(self):
        self.view.setLabelText('Quitting')
        answer = messagebox.askokcancel('Ok to Quit','This will quit the program. \n Ok to quit?')
        if answer==True:            self.parent.destroy()

#Add list to string processing for the scrollbox.
    def spaceToUnderscore(self,aList, width):
        # convert spaces to underscores in list items
        # with a triple underscore between list items
        listString = str()
        for  element in aList:
            elementString = '{:{width}}'.format(element, width=width) 
            elementString = elementString.replace(' ','_')
            listString = listString + '___' + elementString
        return listString
    def elementListToString(self,aList):
        listString = str()
        for  element in aList:
            if len(listString)>0:
                listString = listString + '\t' + element
            else:
                listString = element
        return listString
    def listToListString(self,aList):
        #a temporary method for making lists compatible
        #with listbox till I find something better
        #returns a string we can place in a StringVar
        listString = str() 
        for record in aList:
            elementString = self.spaceToUnderscore(record,20)
            listString = listString + '\n' + elementString
        return listString
    def listToTextString(self,aList):
        #method for making lists compatible
        #with the text box
        #returns a string we can insert in the text box
        listString = self.reportHeader 
        for record in aList:
            elementString = self.elementListToString(record)
            listString = listString + '\n' + elementString
        return listString
            
#delegate for the model            
    def modelDidChangeDelegate(self):
        aList = self.model.getList()
        aListString = self.listToListString(aList)
        self.view.setDataList(aListString)
        self.view.setTextboxText(self.listToTextString(aList))
    
class MyView(Frame):
#Change parent to vc and add a frame 2014 May 26 a  
    def __init__(self,vc):
        self.frame=Frame()
        self.frame.grid(row=0,column=0)
        self.vc = vc
        
        #properties of the view 
        self.labelText = StringVar()
        self.labelText.set("Popper's Penguins Ready")
        self.penguinType = StringVar()
        self.penguinType.set('Penguin Type')
        self.penguinAction = StringVar()
        self.penguinAction.set('Penguin Action')

#Add the control variable
        self.dataList = StringVar()
        self.dataList.set ('')
# instantiate the text box
        self.selectedLine = 1
        self.dataList_textbox = Text(self.frame)       
        self.loadView()

 #       self.makeStyle()
         

#Setters and getters for the properties 2014-May 26
    def setLabelText(self,newText):
        self.labelText.set(newText)
    def getLabelText(self):
        return self.labelText.get()
    def setPenguinType(self,newType):
        self.penguinType.set(newType)
    def getPenguinType(self):
        return self.penguinType.get()
    def setPenguinAction(self,newAction):
        self.penguinAction.set(newAction)
    def getPenguinAction(self):
        return self.penguinAction.get()
#Add setters and getter for data list
    def getDataList(self):
        #returns a string
        return self.dataList.get()
    def setDataList(self,listString):
        #needs a list string delimtied by a space
        self.dataList.set(listString)
#Setters and getters for the text widget
    def setTextboxText(self,listString):
        #delete everything in a text box and replace with the text
        self.dataList_textbox.delete("0.0",END)
        self.dataList_textbox.insert('0.0',listString)
    def getTextBoxtext(self):
    #return a string of all the boxes contents
        return self.dataList_textbox.get('0.0',END)
    def getSelectedLine(self): #for use externally as an index. Do not use the property straight
        return self.selectedLine - 1


# Internal behavior for the text box                               
    def mouseSelectTextLine(self, event):
        mousePosition = "@"+str(event.x)+","+ str(event.y) #get the mouse position
        self.selectedIndex = self.dataList_textbox.index(mousePosition) #convert to textbox index
        splitIndex = self.selectedIndex.split('.') #get the row we are on
        selectedLine = splitIndex[0]
        self.highlightTextLine(selectedLine)
 
    def kbSelectTextLine(self,event):
        selected=self.selectedLine
        if event.keysym == "Up":
            selectedLine = str(selected - 1)
        else:
            selectedLine = str(selected + 1)
        self.highlightTextLine(selectedLine)
    
    def highlightTextLine(self,selectedLine):     
        self.selectedLine=int(selectedLine)
        selectedLineStart = selectedLine + ".0" #beginning and end of line
        selectedLineEnd = self.dataList_textbox.index(selectedLine + ".end")
         #highlight the line on the box
        self.dataList_textbox.tag_delete("selectedLine")
        self.dataList_textbox.tag_add("selectedLine",selectedLineStart,selectedLineEnd)
        self.dataList_textbox.tag_config("selectedLine",background = "blue",foreground = "white")
                
      
#Style Sheet
    def makeStyle(self):
        self.s = ttk.Style()
        self.s.configure('TFrame',background = '#5555ff')
        self.s.configure('TButton',background = 'blue', foreground = '#eeeeff', font = ('Sans','14','bold'), sticky = EW)
        self.s.configure('TLabel',font=('Sans','16','bold'),background = '#5555ff', foreground = '#eeeeff')
        self.s.map('TButton', foreground = [('hover','#5555ff'), ('focus', 'yellow')])
        self.s.map('TButton', background = [('hover', '#eeeeff'),('focus','orange')])
        self.s.configure('TCombobox',background = '#5555ff',foreground ='#3333ff',font = ('Sans',18))
   
#loading the view
#changed the command= to refer to the view controller 2014-May-26
    # self.addPressed now is self.vc.addPressed
    # self.quitPressed now is self.vc.quitPressed
    def loadView(self):
        #label
        buttonFrame = ttk.Frame(self.frame)
        buttonFrame.grid(row = 1, column = 0)
        
        status_label = ttk.Label(self.frame, textvariable = self.labelText)
        #status_label.configure(font=('Sans','16','bold'),background = 'blue', foreground = '#eeeeff')
        status_label.grid(row=0,column=0,sticky=NSEW)
 
        add_button = ttk.Button(buttonFrame,command= self.vc.addPressed,text = 'Add')
        add_button.grid(row = 0, column = 0,sticky = NSEW)
        quit_button = ttk.Button(buttonFrame, command = self.vc.quitPressed, text = 'Quit')
        quit_button.grid(row = 0, column = 1,sticky = NSEW)
 
        penguinType_values = ['Adele','Emperor','King','Blackfoot','Humboldt','Galapagos','Macaroni','Tux','Oswald Cobblepot','Flippy Slippy']
        penguinType_combobox = ttk.Combobox(buttonFrame,values = penguinType_values, textvariable = self.penguinType)
        penguinType_combobox.grid(row =1, column = 0,sticky = EW)
 
        penguinAction_values = ['Happy','Sad','Angry','Standing','Swimming','Eating','Sleeping','On Belly','Plotting Evil','Singing','Dancing','Being Cute']
        penguinAction_combobox = ttk.Combobox(buttonFrame, values = penguinAction_values, textvariable = self.penguinAction)
        penguinAction_combobox.grid(row=1, column = 1,sticky = EW)

        listFrame = ttk.Frame(self.frame)
        listFrame.grid(row =2,column = 0)
        
        dataList_yScroll = Scrollbar(listFrame, orient=VERTICAL)
        dataList_yScroll.grid(row=0,column=1,sticky = NS)
    
        dataList_listbox = Listbox(listFrame, listvariable= self.dataList, width = 40)
        dataList_listbox.grid(row=0, column = 0, sticky=NSEW)
#bind the listbox and scrollbar
        dataList_listbox.configure(yscrollcommand = dataList_yScroll.set)
        dataList_yScroll.configure(command=  dataList_listbox.yview)
#set up the text box
        self.dataList_textbox = scrolledtext.ScrolledText(listFrame, tabs= ("10c"))
        self.dataList_textbox.grid(row=1, column = 0, sticky = NSEW);
 #       self.dataList_textbox.insert("0.0","Penguin\tPenguin Action") #test data
#Added event binding to a button release
        self.dataList_textbox.bind('<ButtonRelease>',self.mouseSelectTextLine)
        self.dataList_textbox.bind('<Down>',self.kbSelectTextLine)
        self.dataList_textbox.bind('<Up>',self.kbSelectTextLine)        

#(1)adding the model 2014-May-28 with convenience method
class MyModel():
    def __init__(self,vc):
        self.vc = vc
        self.myList = []
        self.count = 0
#(2)Delegate goes here. Model would call this on internal change
    def modelDidChange(self):
        self.vc.modelDidChangeDelegate()
#Setters and getters for the model   
    def getList(self):
        return self.myList
    def addToList(self,item):
        myItem = item
        myList = self.myList
        myList.append(myItem)
        self.myList=myList
        self.modelDidChange()
    def getItemAtIndex(self,index):
        return myList[index]
#other methods
    def addRecordToList(self,penguin,action):
        self.addToList([penguin,action])
    

         
def main():
    root = Tk()
#(8) Set up the main loop 2014 May 26
    frame = Frame(root, background="#5555ff", takefocus = 0)
    root.title("Popper's Penguins")         
    app = MyViewController(root)
    root.mainloop() 
 
 
if __name__ == '__main__':
    main()  

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: