What is the most sensible way to format tkinter.ttk.Spinbox widget's contents

Hello all,

I’ve refrained from asking here as I was trying to figure this out myself, and while I have come up with a working solution, it feels so complicated and unoptimised; I’m sure there must be a better way.

The ttk.Spinbox widgets in my application are used to enter a monetary value, and I’m trying to implement the following behaviours:

  • values the user can enter should be restricted to amounts in the ones, tens or hundreds of pounds (or euros, dollars, dollarydoos, etc.) and may include a decimal point followed by the number of pence (or cents). Users may also enter 0.00

  • if the user enters an incomplete amount, i.e an amount that does not include the decimal point and pence, the value should be completed for them upon the widget losing focus, or the user pressing enter

  • if the user enters one of a subset of amounts that are essentially equal to zero, the value of the widget will be set to 0.00

I feel like this is pretty standard behaviour that similar programs should exhibit, so I feel like there must be a more standard way to achieve it. My own solution is composed of two parts, first a validation handler that checks the hypothetical ‘new’ value against a list of regular expressions, and returns true if it finds a match. This is the only way I could get the widget to check the users input upon each keypress and in real time while allowing all valid additions and disallowing all invalid ones…

def rateValHandler(new_value, current_value, event_type):

    valid = False
    patterns = ['^0.00$', '^\d{,3}$', '^\d{,3}\.$', '^\d{,3}\.\d{1}$', '^\d{,3}\.\d{2}$']

    try:
        if new_value[0] == '0' and new_value[1] != '.':
            return valid
    except IndexError:
        pass

    if event_type == 'forced':
        for i in patterns:
            if match(i, current_value):
                valid = True

    if event_type == 'key':
        for i in patterns:
            if match(i, new_value):
                valid = True

    if event_type == 'focus':
        pass

    return valid

This part of the code ensures both that no invalid characters are entered, and that all valid characters entered are in the right position (e.g a decimal point can only be new_value[i] for i in (1, 2, 3), if new_value[0] == ‘0’ then new_value[1] == ‘.’, etc.) however it doesn’t handle the auto-completion of entries upon a <KeyPress-Return> or <FocusOut> event.

For that, I bound to a <KeyPress-Return> and <FocusOut> event on all widgets with the bindtag ‘TSpinbox’ an event handler that I called rateDecimalCompletion…

def rateDecimalCompletion(e):

    value = e.widget.get()

    ### a subset of values are equal to 0.00 ###

    for i in ['', '.', '.0', '.00', '0', '0.', '0.0']:
        if value == i:
            e.widget.set('0.00')
            e.widget.icursor('end')
            return

    ### a decimal point followed by one digit is bookended by zeros (0); a decimal point followed by two digits is prefixed with a
    # zero; a value with only one digit after the decimal point is appended with a zero. A decimal point on it's own is handled
    # by the previous block of code, and a single digit followed by a decimal point is handled by the block of code after this one.
    # Notice that a subset of the values that would be matched by the regex expressions in this code are handled instead by the
    # previous block which, upon ascertaining that the value of 'value' is part of that set, sets the value of the spinbox widget to
    # '0.00' and returns to the main routine - meaning this snippet never handles any value that is a member of that set ###

    if match('^\.\d$', value):
        e.widget.set('0' + value + '0')
    if match('^\.\d{2}$', value):
        e.widget.set('0' + value)
    if match('^\d{1,3}\.\d$', value):
        e.widget.set(value + '0')

    ### values ending in a decimal point are appended with two zeros (00); like the above code snippet, values handled by the initial
    # code snippet will never make it this far, e.g '0.0' and '0.' ###

    elif value.endswith('.'):
        e.widget.set(value + '00')
    elif len(value) < 4 and '.' not in value:
        e.widget.set(value + '.00')

    e.widget.icursor('end')

So basically it all works okay, there is no value that rateValHandler will allow to be entered into the spinbox that is a) not valid in the context of the program or b) that the auto completion code in rateDecimalCompletion cannot complete in the event that the user hits enter or tabs out of the widget before entering a fully formed monetary value.

I can’t say I’m proud of this hodgepodge, and I’m sure there must be an easier way. If anyone can give me any advice it would be much appreciated!

I’ve refrained from asking here as I was trying to figure this out
myself, and while I have come up with a working solution, it feels so
complicated and unoptimised; I’m sure there must be a better way.

Just looking at your code, I think there’d be a only a few things I’d do
very differently:

  • I’d make a subclass of ttk.Spinbox called DecimalSpinbox or
    something like that, so that it is a component you can use easily
    elsewhere or multiple times
  • I’d store the actual value (not a string) as an attribute of that
    class, and update the display from that value
  • I’d probably either store the value as an integer number of the
    minor currency unit (eg pence) or use a decimal friendly numeric
    value (i.e. not a Python float) for the value; I suggest the
    Python Decimal class:
    decimal — Decimal fixed point and floating point arithmetic — Python 3.11.2 documentation

We can get into why float is a terrible choice for decimal fractional
value separately if you care.

So (completely untested):

 from decimal import Decimal, localcontext, quantize, InvalidOperation

 def DecimalSpinbox(ttk.Spinbox):

     def __init__(self, parent, *a, value=None, **kw):
         if value is None:
             value = 0.0
         super().__init__(parent, parent, *a, **kw)
         self.set(value)

     def set(self, new_value):
         # NB: assumes 100 minor currency units
         try:
             self.currency_value = Decimal(new_value).quantize(Decimal('1.00'))
         except InvalidOperation:
             # ignore the new value
             pass
         else:
             # update the text in the widget
             super().set(str(self.currency_value))

This keeps the “real” currency value as the .currency_value attribute,
which you can just inspect. As written, it still uses .set to update
the display. I’m not sure how smoothly this will work but I like the
idea of having the “real” value are as real attribute, with the
displayed stuff a “view” of the real value.

This also has the handy effect that the quantised Decimal itself zero
pads to 2 places for you, avoiding need for your “completion” code which
padding '.' to '.00' and '.n' to '.n0'.

You still need validation stuff because a Spinbox is really an Entry
widget with embellishments, and so it is a small text display.

The other thing you get is validation via the Decimal type for free.
Some examples:

 >>> Decimal('2').quantize(Decimal('1.00'))
 Decimal('2.00')
 >>> Decimal('2.').quantize(Decimal('1.00'))
 Decimal('2.00')
 >>> Decimal('2.1').quantize(Decimal('1.00'))
 Decimal('2.10')
 >>> Decimal('2.101').quantize(Decimal('1.00'))
 Decimal('2.10')
 >>> Decimal('2.a').quantize(Decimal('1.00'))
 Traceback (most recent call last):
   File "<stdin>", line 1, in <module>
 decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]

You may or may not still want your regexp based checks if you want to
further constrain the import (eg to catch '2.101' above as invalid).

Some random remarks about your existing code.

 def rateValHandler(new_value, current_value, event_type):
     valid = False

This is good. Validation should start false and only become true on
complete passing of your tests.

 patterns = ['^0.00$', '^\d{,3}$', '^\d{,3}\.$', '^\d{,3}\.\d{1}$', '^\d{,3}\.\d{2}$']

I recommend writing regexps as “raw strings”:

 patterns = [r'^0.00$', r'^\d{,3}$', r'^\d{,3}\.$', r'^\d{,3}\.\d{1}$', r'^\d{,3}\.\d{2}$']

This is because nonraw Python strings also use the backslash for
punctuation (eg '\n' to mean a newline character) and using a raw
string eliminates a lot of confusion, not least any need to double the
backslashes. So example, in a regexp you need to express a literal
backslash as a backslashed backslash:

 \\

and in a nonraw string you need to write:

 '\\\\'

Ugh. With a raw string you can stay in regexp-syntax-land:

 r'\\'

So, back to your code:

     try:
         if new_value[0] == '0' and new_value[1] != '.':
             return valid
     except IndexError:
         pass

I’d just return False directly here, since you’re returning “early”
(not at the end of the function with return valid). I’d probaly write
this test as:

 if new_value.startswith('0') and not new_value.startswith('0.'):
     return False

Avoids a lot of fiddly length/index checking.

 if event_type == 'forced':
     for i in patterns:
         if match(i, current_value):
             valid = True

I’d probably break on a match i.e.:

 for i in patterns:
     if match(i, current_value):
         valid = True
         break

Avoids wasting effort on the remaining regexps.

But if the Decimal class validation is enough you can avoid all the
regexps. Or you can keep some to eliminate some extra forbidden (but
Decimal-valid) string you don’t want to accept.

Cheers,
Cameron Simpson cs@cskk.id.au

Thank you very much for your detailed reply, I wasn’t sure if I was completely off with the way I was going about it, so it’s nice to know it’s somewhat valid. I can’t address all your points immediately but I’ve taken them on board. I’ll start with this though…

There is one thing that I don’t fully understand: the .set method of the subclass in your example takes a new_value, turns it into a decimal.Decimal object and calls .quantize on it and then stores the value returned in self.currency_value, then calls the parent class’s .set method and passes in self.currency_value (so the decimalised and quantized new_value)

I understand the rationale, storing the value as a more appropriate, application specific object and then by setting the value of the parent widget to str(self.currency_value) it’s like a view into something a bit more… pure?

What I’m not sure of is, when the user updates the value of the Spinbox via the UI, does that invoke the .set method of the widget behind the scenes? If so, presumably passing in the new value of the widget as it’s argument…

Also, a word on Decimal.decimal.quantize: I worked out how to use it to round a value to a certain precision (which to be fair is exemplified right there in your example) but I can’t say I understand the maths (I’m not a real programmer!) even after reading the docs. I don’t think I need to for this purpose, but it does make it fell like a bit of a cheat :anguished:

I understand the rationale, storing the value as a more appropriate,
application specific object and then by setting the value of the parent
widget to str(self.currency_value) it’s like a view into something a
bit more… pure?

Something which exactly represents the value you’re managing. A float
won’t really quite do that. I was tempted to offer Plato’s parable of
the cave, where the shadows on the cave wall (which we humans see in the
world) are just a view of the true forms which cast them, which we as
humans do not see. So it is with your GUI :slight_smile:

What I’m not sure of is, when the user updates the value of the Spinbox via the UI, does that invoke the .set method of the widget behind the scenes?

And I don’t know, alas. You’ll have to test that (lots of print()
calls). The Spinbox may well directly update it’s internal float,
bypassing the set() method.

If that’s the case, rather than hackingoverriding its internals, maybe
what you want to do is instead make the currency_value attribute a
property:

 @property
 def currency_value(self):
     ''' The internal `float` presented as a correctly rounded `Decimal` instance. '''
     return Decimal(self.get()).quantize(Decimal('1.00'))

That’s less intrusive, but puts you back into the complex validation you
were doing before. If the SpinBox does use set() internally for
all updates of its float things will come out cleaner.

Also, a word on Decimal.decimal.quantize: I worked out how to use it
to round a value to a certain precision (which to be fair is
exemplified right there in your example) but I can’t say I understand
the maths (I’m not a real programmer!) even after reading the docs.

Oh, the decimal module is a bit quirky. It looks like it implements
this: General Decimal Arithmetic
which itself conforms to some other standards which it cites in the
opening paragraph. This is my first attempt to use it myself.

The core issue is that a Python float is effectively a scaled
fraction, in base 2. As such it cannot represent values like .1 or .01
with perfect precision because that fraction (1/10) involves 5 as one of
its prime factors. Base 2 is compact and efficient for computed (which
have binary storage); switching bases doesn’t really help and generally
makes things worse - you’re just switching the values which cannot be
perfectly represented.

So instead you can choose a different math (decimal, here).

For what you’re doing the float may be ok.

Cheers,
Cameron Simpson cs@cskk.id.au