11
\$\begingroup\$

When I'm throwing together prototype code, I don't want to spend extra time setting up parameters, or chasing down problems from entering parameters wrongly. So this is a general purpose, fairly easy to use, GUI data input helper, that hopefully takes care of most of the data entry whoopsies that frustrate just getting the damned target code working.

I really had trouble with deciding whether each entry line should be an object in its own right. The code shown here takes that approach, but as a drawback the field widths are fixed so that they line up nicely, and there's an update callback. In an earlier version, the top level widget built everything from individual Label, Entry and Button widgets, which took care of a lined-up layout automatically and didn't need the update callback, but was a bit untidier around keeping the corresponding elements together, and needed closures for trace callbacks to check the right data.

I changed whether I had lists of keys and entry lines, or a dict of entries keyed on the keys several times, neither seems really tidy, though the lists implementation I have now is not too bad.

Anyhow, it runs. Have I missed a better way of designing it that would have me feeling better about it? It has a __name__ == '__main__', so run it and have a play.

""" class to use as a general purpose GUI input widget
and a few convenience functions for validating data inputs
"""

import tkinter as tki
import tkinter.messagebox as tkm

class MyLabelEntry(tki.Frame):
    """ a combination of label, entry and help button, for validated gui entry of data"""
    def __init__(self, parent, text='label', data='', conv=None, update=None):
        """ text    optional    used to label the entry

            data    optional    used to initialise the Entry box
                                uses empty string if none supplied

            conv    optional    conversion function, which also validates the entry
                                note that int and float can be used
                                return string unchanged if omitted or None
                                return object if entry is valid
                                its __doc__ string is available as help
                                if the docstring starts with [x], x is put on the help button
                                (have to work on tooltips sometime!)
                                raise ValueError if the entry is invalid
                                The err from ValueError is saved for the help button
                                so more info can be given about failure to validate

        """
        tki.Frame.__init__(self, master=parent)
        self.update = update

        # do the properties
        self.err = ''
        self.value = None

        # do the label
        self.label = tki.Label(self, text=text, width=15)
        self.label.grid(row=0, column=0)

        # do the conversion function
        self.conv = conv
        cdoc = conv.__doc__  # easier to type
        if conv:
            # we have a conversion function specified
            # is it one of the builtins?
            if conv == int:
                help_face = 'i'
                self.conv_help = 'builtin int() function'
            elif conv == float:
                help_face = 'f'
                self.conv_help = 'builtin float() function'
            else:
                # neither of those, so does it have a docstring?
                if cdoc:
                    # yes, does it start with a help_face?
                    face_end = cdoc.find(']')
                    if (cdoc[0] == '[') and (face_end != -1) and (face_end < 6):
                        help_face = cdoc[1:face_end]
                    else:
                        help_face = '?'
                    # is the help prompt truncated in the docstring?
                    help_end = cdoc.find('[end_help]')
                    if help_end != -1:
                        self.conv_help = cdoc[:help_end]
                    else:
                        self.conv_help = cdoc                    
                else:
                    self.conv_help = 'no documentation\nfor this conversion'
                    help_face = '?'
        else:
            self.conv = str
            help_face = '='
            self.conv_help = 'unmodified string'

        # do the entry
        self.var = tki.StringVar()
        self.entry = tki.Entry(self, textvariable=self.var, width = 15)
        self.entry.grid(row=0, column=1)
        self.var.trace('w', self._changed)

        # do the help button
        self.help_but = tki.Button(self, text = help_face, command=self._show_help,
                                   width=5, takefocus=0)   # don't take part in tab-focus
        self.help_but.grid(row=0, column=2)

        # initialise it, which triggers the trace, _change and validation
        self.var.set(str(data))

    def _show_help(self):
        tkm.showinfo('conversion information', '{}\n{}'.format(self.conv_help, self.err))

    def _changed(self, *args):
        ent_val = self.var.get()
        try:
            self.value = self.conv(ent_val)
            self.entry.config(bg='white')
            self.err = ''
            self.valid = True
        except ValueError as err:
            self.value = None
            self.entry.config(bg='orange')
            self.err = err
            self.valid = False
        self.update()

class GUI_inputs(tki.LabelFrame):
    """ A GUI data input convenience class"""
    def __init__(self, parent, text='Input Widget', execute=None):
        """ initialise with text for the LabelFrame
            if execute is defined, create an 'executeute' button in the frame
            greyed out until all entries are valid
        """
        tki.LabelFrame.__init__(self, master=parent, text=text)

        # we have a list of entries
        self.entries = []    # the data entry widgets
        self.keys = []      # the keys, both lists will stay in the same order
        self.row = 0

        # if there's a execute supplied, put up a button for it, on the last row
        self.execute_func = execute
        if execute:
            self.execute_but = tki.Button(self, text='execute',
                                           command=self.execute_func,
                                           state=tki.DISABLED)
            self.execute_but.grid(row=99, column=0)    

    def add(self, key, disp_name='', data='', conv=None):
        """ add a new line to the input widget

        key         required    key for the entry, must be unique
        other arguments follow from MyLabelEntry() usage above                                     
        """

        if key in self.keys:
            raise ValueError('duplicate key name >>>{}<<<'.format(key))

        if not disp_name:
            disp_name = str(key)

        mle = MyLabelEntry(self, disp_name, data, conv, self.update)
        mle.grid(row=self.row)
        self.row += 1
        self.entries.append(mle)
        self.keys.append(key)

    def update(self):
        """ called when something has changed"""
        # only need to worry about this when there's a execute button to grey-out
        if self.execute_func:
            # get the valid properties of each entry
            valids = [x.valid for x in self.entries]
            if all(valids):
                self.execute_but.config(state=tki.NORMAL)
            else:
                self.execute_but.config(state=tki.DISABLED)            

    def get(self):
        """ return a dict of the results"""
        output = dict(zip(self.keys, [x.value for x in self.entries]))
        return output

def intfloat(x):
    """[if] a float accepted, truncated to an integer for return"""
    return int(float(x))

def int16(x):
    """[16] a base16 (hexadecimal) integer"""
    return int(x, base=16)

def float_pair(x):
    """[f,f] Two floats seperated by a comma
    all three elements are required
    [end_help]

    example non-trivial conversion function
    not all of docstring intended to be displayed as help
    throw two different types of ValueError, one from split, one from float
    return a list of the values

    """
    fields = x.split(',')
    if len(fields) != 2:
        raise ValueError('need two fields seperated by one comma')
    output = []
    for field in fields:  # no need to try:, ValueError will burn through
        output.append(float(field))
    return output    

if __name__ == '__main__':

    def execute_func():
        print('executing with')
        print( ml.get(), basic.get())

    def cryptic_conv(x):
        # no docstring for this conversion function
        return int(x)

    root = tki.Tk()

    basic = GUI_inputs(root, 'basic')
    basic.pack()
    basic.add('key 1')
    basic.add('key 2')

    ml = GUI_inputs(root, 'more flexible', execute=execute_func)
    ml.pack()
    ml.add('we')
    ml.add('we1', conv=int)
    ml.add('we2', 'disp4we2', data=999)
    ml.add('pair', 'f_pair','',float_pair)
    ml.add('cryp', 'no doc string', 6, cryptic_conv)

    root.mainloop()
\$\endgroup\$
3
  • 1
    \$\begingroup\$ You use MyLabelEntry (with camel case) as the name of one class, and GUI_inputs (with snake case) as the other. I know the PEP8-prescribed "GUIInputs" is ugly as sin, so you might consider a name change to GUIData or some name without "GUI" in it. Also, naming things with "My" is a pet peeve of mine :) \$\endgroup\$
    – tsleyson
    Sep 10, 2014 at 0:15
  • \$\begingroup\$ @tsleyson you know, you could expand on that in an answer. I'd upvote it ;-) (ping me when you do) \$\endgroup\$
    – janos
    Dec 13, 2014 at 18:24
  • 1
    \$\begingroup\$ @janos Done, thanks for your support. I felt bad making a whole answer just to crab about naming conventions, so I read the code a little closer and tried to add some other comments. \$\endgroup\$
    – tsleyson
    Dec 15, 2014 at 23:16

2 Answers 2

6
\$\begingroup\$

I ran your code and messed around a little with it, and it works pretty good. You also have a pretty cool idea here, that I might have to steal next time I do a TKinter GUI.

As far as high-level design, I agree with your decision to have each entry line be its own object—it's much easier to think about than the other approach you described, with the GUI_Input class directly managing the TKinter objects. You've added enough behavior that these MyLabelEntry things aren't really the same as their constituent objects anymore, so it makes sense to make a new class that deals with that.

I don't really like your list approach, though. It looks to me like you're doing parallel arrays, something that gets used in C a lot since it doesn't have a real dictionary type. You rely on the keys and values (entries) being at the same position in two different lists. Normally, I would prefer dictionaries. If order is important, you can use the Collections.OrderedDict class, assuming you're using Python 2.7 or above. (It looks from your TKinter code like you're using Python 3.) To me, an ordered dictionary feels cleaner and more Pythonic, and there's no danger (however remote) of your keys and values getting out of sync. (I say this as someone who spent a year coding all my dictionaries as parallel lists because of the habits I developed after taking C++ at university.)

As I mentioned in my comment, I have some issues with the way you've named things. Per PEP-8, the standard is to use CamelCase for class names and snake_case for functions and methods. I realize that GUIInputs is horrible, though; in Java they would write GuiInputs, but PEP-8 prefers that you keep fully capitalized acronyms fully capitalized. To get around this, I'd recommend just changing the name. If I'm reading the code right, GUI_inputs is a container for MyLabelEntry instances, so you could call it something like LabelEntryContainer.

I'd also recommend changing MyLabelEntry. It's a pet peeve of mine, but I hate it when people name things with "my". It seems to be a Perlism; in that language, you declare variables with my the way Javascript and Scala declare them with var. I dislike "my" because it makes names longer without really adding more info. Really, what does MyLabelEntry have that LabelEntry doesn't? As far as I can tell, two extra letters.

Finally, I'd recommend a cleaner way to manage conversion functions inside MyLabelEntry. What you have now is basically a giant switch statement, which somewhat defies extensibility. Functions in Python are first-class, so you could instead store your conversion functions in a dictionary. I tried it out, and it looks like functions are even hashable, so you can use them as dictionary keys if that's what you need. Your dictionary values could be tuples or dictionaries which give the settings of variables in each case. Let me know if you need to see an example, and I'll edit to add.

This approach would also strip out a lot of code in your __init__ function working with conv. This is good, because it's usually considered better style to keep your __init__ function lean. If you need to do something complicated, you can write helpers (which would be private in Java) and call them from __init__.

It could use a little cleaning up, but you've got some pretty well-designed code here, and a good idea for a helper class to speed along GUI development.

\$\endgroup\$
2
\$\begingroup\$

Adding the following to the top is all you need to make this code bacwards compatible to Python 2.

import sys
if sys.version == 2:
    import Tkinter as tki
    import tkMessageBox as tkm
else:
    import tkinter as tki
    import tkinter.messagebox as tkm
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.