DEV Community

geraldew
geraldew

Posted on

Python Tkinter - An Exercise in Wrapping the ComboBox

I recently solved a small problem by wrapping a sub-class around theTkinter ComboBox definition. Having made notes as I was doing so, I figured I may as well tidy and share what I did.

There are plenty of tutorials around about doing this kind of thing - I'm not claiming this to be anything special or better.

The Problem

It so happens that the environment in which I have done most of my application coding over the years is one that provides a "ComboBox" control aka a drop-down list.

But in that environment I am very used to having the ComboBox fed by a small table, usually as rows with key and show columns. The control is bound to the key but displays the show columns - this being achieved by setting the dispay width of the first column to zero. Thus the Combobox has values just for displays but when I read what the user selected, I get the key. Most of the time I use numeric keys and string displays. When supplying these via a table, an order can be specified - that might be independent of the display values.

Having come to writing applications in Python and using Tkinter, it has been frustrating that this only supports the idea of the displayed values being exactly what the control returns as the user's selection.

While annoying, this issue just hadn't been important enough to go out of my way to solve. For some reason, I suddenly felt like tackling - hence this annotated journey.

  • Note: I am deliberately overlooking that we can fetch the index of the selected item, rather than the displayed value. Most of the time, I don't really care about where things are in the displayed drop-down list - and want to be free to re-order them, or leave an item out. However, as we'll later on, the index might be a useful once we've worked how to wrap one class inside another.

Enter Enumerations

As an old Pascal programmer, I quite like using defined types and enumerations in my programming. The most important reason for liking them - especially as a combination - is that a compile phase will pick up any attempt to stray from assigning across the type definitions.

However, Python as an interpreted language with dynamic typing doesn't really provide that benefit. Nonetheless I still quite like using enumerations as it lets me write symbolic values in the program code and ignore the specific values much of the time.

To quote from WikiBooks: Pascal Programming/Enumerations

type
    weekday = (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday);

var
    startOfWeek: weekday;
begin
startOfWeek := Sunday;
end.
Enter fullscreen mode Exit fullscreen mode

For internal use for a compiled program, the actual (binary) values that will be used for the enumeration values simply don't matter. And being a strongly typed language, the Pascal compiler will give an error for any attempt to assign a non "weekday" value to the variable "startOfWeek".

Of course, Python is quite different, and I won't explain that in detail - see the standard Python documentation for enum — Support for enumerations - but suffice to say I find them useful enough to use them.

However this does pose a problem with comboboxes - because they need to be fed with strings to display and therefore they return those strings back as the selected value.

To get around this, I have been creating my enumerations, and then adding to them by writing functions to express each enumerated value as a display/select string and a converse function to get the enumeration that has a specific string. It all works, but seems clumsy.

What I would like is to have Comboboxes to which I can pass a list of pairs, such that the string halves get displayed to the user but I can get back the enumeration half (key) for that string.

Current Approach

Currently, what I do is to have a Tk variable that is bound to the textvariable property of the Combobox

Hence the setup portion of the code does:

  • create Tk variable
  • create Tk Combobox control with binding to the variable
  • set the values for the Combobox control
  • set the default value for the Combobox by setting the bound variable
    t06_t2_str_scheme = m_tkntr.StringVar() 
    t06_t2_cb_schemes = m_tkntr_ttk.Combobox( t06_t2_frame02, width = 50, textvariable = t06_t2_str_scheme )
    t06_t2_cb_schemes['values'] = cmntry.MatchScheme_GetTuple()
    t06_t2_str_scheme.set( cmntry.MatchScheme_Default_Value() )

Enter fullscreen mode Exit fullscreen mode

In which I'm pulling from some support functions that I define along with my enumerations - namely:

  • MatchScheme_GetTuple()
  • MatchScheme_Default_Value()

Then, to later read the selection, we just get the value of the bound variable

    got_selection = t06_t2_str_scheme.get()

Enter fullscreen mode Exit fullscreen mode

And for which I will then need to call another function to translate from the selected string to the corresponding enumeration.

    enum_selection = MatchScheme_EnumOfString( got_selection)

Enter fullscreen mode Exit fullscreen mode

It's not that doing this is hard, it's just fiddly.

What I Want To Do

I want to be able to

  • have a list of tuples as what I set for the Combobox
  • in which the first value in each tuple is the value I want to get back
  • the second value in each tuple is the value I want displayed for selection

e.g. define the list of tuples

    the_ListTuplePair = [ ( 1, "One" ), ( 2, "Two"), ( 3, "Three"), ( 4, "Four") ]

Enter fullscreen mode Exit fullscreen mode

e.g. create the Combobox object

    t06_t2_cb_schemes = TuplePairCombobox( t06_t2_frame02, width = 50, listTuplePair = the_ListTuplePair, defaultKey = 2 )

Enter fullscreen mode Exit fullscreen mode

e.g. the collection of the selection

    the_selection = t06_t2_cb_schemes.getSelectedKey()

Enter fullscreen mode Exit fullscreen mode

Caveats and Cases

While mulling over some of the potential issues, I considered I would need to deal with:

  • that no repeats of the keys should get processed and displayed, and hence not selected
  • that repeats of the display values is allowed and catered for
  • a default key that matches no provided key means no default value is set
  • that no selection being made returns as None

Implementation A - using the value bound to the control

The Air Code

Reminding myself a little bit about how class definitions work in Python (I do tend to avoid them) I sat down and typed up the following (with a fair bit of toying around):

class TuplePairCombobox( m_tkntr_ttk.Combobox ):
    def _process_listTuplePair( ip_listTuplePair ):
        # ensure the listTuplePair has no repeats of the keys
        t_list_keys = [] # used to detect repeats
        r_dict_valu2key = {} # use both to populate the combobox and reverse lookup after selection
        for tpl in ip_listTuplePair:
            if tpl[0] in t_list_keys :
                pass
            else
                if tpl[1] in i_dict_valu2key:
                    n = t_list_keys # number of space to pad the display string
                    i_dict_valu2key[ tpl[1] + ( " "*n ) ] = tpl[0]
                else
                    i_dict_valu2key[ tpl[1] ] = tpl[0]
                t_list_keys.append( tpl[0] )
        return r_dict_valu2key
    def __init__(self, container, p_listTuplePair, p_defaultKey, *args, **kwargs):
        self.i_StringVar = m_tkntr.StringVar() 
        i_dict_valu2key = _process_listTuplePair( p_listTuplePair )
        super().__init__(container, textvariable = self.i_StringVar, *args, **kwargs)
        self['values'] = tuple( list( i_dict_valu2key.keys() ) )
        if p_defaultKey in list( i_dict_valu2key.keys() ) :
            self.i_StringVar.set( self.i_defaultKey )
    def getSelectedKey():
        txt_selection = self.i_StringVar.get()
        r_key = i_dict_tpls[ txt_selection ]
        return r_key

Enter fullscreen mode Exit fullscreen mode

That seemed to cover the most of how the idea would work. A short description might be:

  • process the supplied list of tuples to build a dictionary that will act as both the reverse lookup - to go from the selected display to the tuple keys - and as the list of things to display in the combobox
  • apply my logical rules about repeats, of keys and of values
  • to deal with any repeated values, look for clashes and add extra spaces to their ends, with different numbers of spaces as we go along

The Actual Code

Here is what I had after pasting in the air code and fixing some of its oversights.

The Class

class TuplePairCombobox( m_tkntr_ttk.Combobox ):
    def _process_listTuplePair( self, ip_listTuplePair ):
        # ensure the listTuplePair has no repeats of the keys
        t_list_keys = [] # used to detect repeats
        r_dict_valu2key = {} # use both to populate the combobox and reverse lookup after selection
        for tpl in ip_listTuplePair:
            if tpl[0] in t_list_keys :
                pass
            else :
                if tpl[1] in r_dict_valu2key:
                    n = len( t_list_keys ) # number of space to pad the display string
                    r_dict_valu2key[ tpl[1] + ( " "*n ) ] = tpl[0]
                else :
                    r_dict_valu2key[ tpl[1] ] = tpl[0]
                t_list_keys.append( tpl[0] )
        return r_dict_valu2key
    def __init__(self, container, p_listTuplePair, p_defaultKey, *args, **kwargs):
        self.i_StringVar = m_tkntr.StringVar() 
        self.i_dict_valu2key = self._process_listTuplePair( p_listTuplePair )
        super().__init__(container, textvariable = self.i_StringVar, *args, **kwargs)
        self['values'] = tuple( list( self.i_dict_valu2key.keys() ) )
        if p_defaultKey in list( self.i_dict_valu2key.keys() ) :
            self.i_StringVar.set( self.i_defaultKey )
    def getSelectedKey( self ):
        txt_selection = self.i_StringVar.get()
        if txt_selection in self.i_dict_valu2key:
            r_key = self.i_dict_valu2key[ txt_selection ]
        else : 
            r_key = None
        return r_key

Enter fullscreen mode Exit fullscreen mode

The Object

Here is how the new feature gets used.

First the creation of the ComboBox object, with the usual Tkinter pair of: create and place steps.

    i_defaultKey = 3
    t02_cb_schemer = TuplePairCombobox( t02_frame50, i_listTuplePair, i_defaultKey, width = 50) 
    t02_cb_schemer.pack( side = m_tkntr.LEFT, padx=tk_padx(), pady=tk_pady() )

Enter fullscreen mode Exit fullscreen mode

The Value Fetch

Here is how the value is collected from the ComboBox.

            print( t02_cb_schemer.getSelectedKey() )

Enter fullscreen mode Exit fullscreen mode

The List of Tuples

Here is a mock example of a list of tuples for supplying to the new TuplePairCombobox object.

    #i_listTuplePair = [ ( 1, "One" ), ( 2, "Two"), ( 3, "Three"), ( 4, "Four") ]

Enter fullscreen mode Exit fullscreen mode

The List of Tuples with a Repeated Value

Just to check that my extra edge case code is working, here is an alternate list of tuples, in which there are two identical display strings.

    i_listTuplePair = [ ( 1, "One" ), ( 2, "Two"), ( 3, "Three"), ( 4, "Two") ]

Enter fullscreen mode Exit fullscreen mode

In that case, all four options are in the ComboBox and can each return a distinct key - i.e. the fact that two display strings are there is worked around.

Caveat:

  • the mechanism for this is very simple, and would fail for some combinations in the source tuples. I will have to decide whether to write something more robust.

The List of Tuples with a Repeated Key

And similarly here is an alternate list of tuples, in which there are two identical key values.

    i_listTuplePair = [ ( 1, "One" ), ( 2, "Two"), ( 2, "Three"), ( 4, "Four") ]

Enter fullscreen mode Exit fullscreen mode

In this case, as per the logic I wrote in, the second appearance of the key 2 will be omitted.

Method B - using the index of the selection

In Method A we just worked around the issue of a Combobox "returning" one of the display values.

But with the standard Tkinter Combobox we do also have the option of pulling out the index of the selected value. Can that be used to make a better method?

Air code

For this, I copied the finished Method A code and changed the definition of getSelectedKey to use current() and then thought my way backwards about what data structure it would require to exist. I chose to build two lists in parallel, so that corresponding keys and show strings would be at the same index number.

That gave me the following code, ready to implant into my program. I reversed part of the name "TuplePair" becoming "PairTuple" so that the usage calls could be easily swapped to test it.

class PairTupleCombobox( m_tkntr_ttk.Combobox ):
    def _process_listTuplePair( self, ip_listTuplePair ):
        r_list_keys = [] 
        r_list_shows = [] 
        for tpl in ip_listTuplePair:
                r_list_keys.append( tpl[0] )
                r_list_shows.append( tpl[1] )
        return r_list_keys, r_list_shows
    def __init__(self, container, p_listTuplePair, p_defaultKey, *args, **kwargs):
        self.i_list_keys, self.i_list_shows = self._process_listTuplePair( p_listTuplePair )
        super().__init__(container, textvariable = self.i_StringVar, *args, **kwargs)
        self['values'] = tuple( self.i_list_shows )
        # still need to set the default value from the nominated key
    def getSelectedKey( self ):
        i_index = self.current()
        return self.i_list_keys[ i_index ]

Enter fullscreen mode Exit fullscreen mode

Actual code

And here is the final code.

class PairTupleCombobox( m_tkntr_ttk.Combobox ):
    def _process_listPairTuple( self, ip_listPairTuple ):
        r_list_keys = [] 
        r_list_shows = [] 
        for tpl in ip_listPairTuple:
            r_list_keys.append( tpl[ 0] )
            r_list_shows.append( tpl[ 1] )
        return r_list_keys, r_list_shows
    def __init__( self, container, p_listPairTuple, p_defaultKey, *args, **kwargs):
        self.i_list_keys, self.i_list_shows = self._process_listPairTuple( p_listPairTuple )
        super().__init__(container, *args, **kwargs)
        self['values'] = tuple( self.i_list_shows )
        # still need to set the default value from the nominated key
        try:
            t_default_key_index = self.i_list_keys.index( p_defaultKey ) 
            self.current( t_default_key_index )
        except:
            pass
    def getSelectedKey( self ):
        try:
            i_index = self.current()
            return self.i_list_keys[ i_index ]
        except:
            return None

Enter fullscreen mode Exit fullscreen mode

The main change is that once it had passed initial testing, I add a try clauses to:

  • handle finding the index of the default value and using current as a setter
  • handle in case no default was set and no selection was made

Summary

As an exercise, this was a nice combination of:

  • giving myself the ability to rework and simplify most of the places where I use a Combobox control
  • a rediscovery that using classes to rework other classes wasn't quite as tricky as I'd expected

I do think I should find a better name for my new classes and/or choose which one of them to keep long term.

Top comments (0)