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.
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() )
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()
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)
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") ]
e.g. create the Combobox object
t06_t2_cb_schemes = TuplePairCombobox( t06_t2_frame02, width = 50, listTuplePair = the_ListTuplePair, defaultKey = 2 )
e.g. the collection of the selection
the_selection = t06_t2_cb_schemes.getSelectedKey()
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
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
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() )
The Value Fetch
Here is how the value is collected from the ComboBox.
print( t02_cb_schemer.getSelectedKey() )
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") ]
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") ]
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") ]
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 ]
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
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)